Skip to content

Commit 5552d1f

Browse files
committed
Add IW6 image scanner and raw-image viewer
Introduce IW6 GfxImage scanning and UI support for inline image-like assets. Adds IW6GfxImageScanner to locate flat-binary GfxImage structs and IW6GfxImageInfo metadata, plus unit tests validating detection and heuristics. Add RawFileImageViewerForm to display/save raw-file JPEG/PNG/BMP/GIF/TIFF/ICO bytes and wire it into the main UI (double-click tree to open, tooltip updated, text editor shows hint for image rawfiles). Update Ghosts parsing: GhostsAssetWalker now emits RawFileNodes for image pool entries and pairs scanner-discovered GfxImage structs into records; GhostsZoneLayout pairing was made name-aware (image extensions route to image-typed slots, with spill-to-fallback behavior). Minor UI/status string tweaks to better describe IW6 flat-binary assets.
1 parent 8668de3 commit 5552d1f

9 files changed

Lines changed: 745 additions & 22 deletions

File tree

Call of Duty FastFile Editor/Call of Duty FastFile Editor.csproj.user

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
<Compile Update="UI\ImagePreviewForm.cs">
1717
<SubType>Form</SubType>
1818
</Compile>
19+
<Compile Update="UI\RawFileImageViewerForm.cs">
20+
<SubType>Form</SubType>
21+
</Compile>
1922
<Compile Update="UI\RawFileSearcherForm.cs">
2023
<SubType>Form</SubType>
2124
</Compile>

Call of Duty FastFile Editor/UI/MainWindowForm.Designer.cs

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Call of Duty FastFile Editor/UI/MainWindowForm.cs

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,16 +1001,36 @@ private void filesTreeView_BeforeSelect(object sender, TreeViewCancelEventArgs e
10011001
_previousSelectedNode = filesTreeView.SelectedNode;
10021002
}
10031003

1004+
/// <summary>
1005+
/// Double-clicking an image-extension raw file (jpg/png/bmp/gif/tiff/ico)
1006+
/// opens it in a modal image viewer. Non-image files fall through —
1007+
/// the text editor on the right is the normal view for those.
1008+
/// </summary>
1009+
private void filesTreeView_NodeMouseDoubleClick(object? sender, TreeNodeMouseClickEventArgs e)
1010+
{
1011+
if (e.Node?.Tag is not RawFileNode node) return;
1012+
if (!RawFileImageViewerForm.IsImageRawFile(node.FileName)) return;
1013+
using var viewer = new RawFileImageViewerForm(node);
1014+
viewer.ShowDialog(this);
1015+
}
1016+
10041017
/// <summary>
10051018
/// Handles actions after selecting a new TreeView node, loading the corresponding file content.
10061019
/// </summary>
10071020
private void filesTreeView_AfterSelect(object sender, TreeViewEventArgs e)
10081021
{
10091022
if (e.Node?.Tag is RawFileNode selectedNode)
10101023
{
1011-
// Load the file content into the editor without retriggering TextChanged
1024+
// Load the file content into the editor without retriggering TextChanged.
1025+
// For image-extension raw files with no text content, show a hint so the
1026+
// user knows to double-click for the modal viewer (rather than seeing
1027+
// a blank panel and assuming nothing's there).
1028+
string display = selectedNode.RawFileContent ?? string.Empty;
1029+
if (string.IsNullOrEmpty(display) && RawFileImageViewerForm.IsImageRawFile(selectedNode.FileName))
1030+
display = $"-- {selectedNode.FileName}{Environment.NewLine}-- Image file ({selectedNode.MaxSize:N0} bytes). Double-click in the tree to open the image viewer.";
1031+
10121032
textEditorControlEx1.TextChanged -= textEditorControlEx1_TextChanged;
1013-
textEditorControlEx1.SetTextAndRefresh(selectedNode.RawFileContent ?? string.Empty);
1033+
textEditorControlEx1.SetTextAndRefresh(display);
10141034
textEditorControlEx1.TextChanged += textEditorControlEx1_TextChanged;
10151035

10161036
// Update UI
@@ -4178,10 +4198,26 @@ private void LoadAssetPoolIntoListView()
41784198
string dataStart = "-";
41794199
string dataEnd = "-";
41804200
string size = "-";
4181-
// Determine appropriate status message based on asset type
4201+
// Determine appropriate status message based on asset type.
4202+
// For Ghosts (IW6), the older games' "External reference (data in
4203+
// another zone)" default is misleading: tracing patch_ui_mp.ff
4204+
// showed 472/510 image-typed entries have FFFFFFFF pointer
4205+
// placeholders, meaning the engine expects their bodies inline
4206+
// in *this* zone — they're flat-binary GfxImage structs +
4207+
// texture data, not external references. We just don't have a
4208+
// parser for the IW6 image struct yet.
41824209
string status;
41834210
bool isSupportedType = isRawFile || isLocalize || isMenuFile || isTechSet || isXAnim || isStringTable || isWeapon || isImage;
4184-
if (isSupportedType)
4211+
bool isGhosts = _openedFastFile.IsGhostsFile;
4212+
if (isGhosts && isImage)
4213+
{
4214+
status = "Flat-binary GfxImage (IW6 image parser not implemented)";
4215+
}
4216+
else if (isGhosts && isSupportedType)
4217+
{
4218+
status = "Flat-binary asset (IW6 content parser not implemented)";
4219+
}
4220+
else if (isSupportedType)
41854221
{
41864222
status = "External reference (data in another zone)";
41874223
}
@@ -4294,7 +4330,9 @@ private void LoadAssetPoolIntoListView()
42944330

42954331
// Generic fallback: if no typed parser claimed this record but the
42964332
// walker resolved its name + body offsets (Ghosts non-rawfile types
4297-
// land here), surface them directly from the ZoneAssetRecord.
4333+
// land here), surface them directly from the ZoneAssetRecord. The
4334+
// walker sets AdditionalData to a descriptive string ("GfxImage
4335+
// 256×256 (color / 2D)" etc.) — use it verbatim as the status.
42984336
if (!isParsed && !string.IsNullOrEmpty(record.Name) && record.HeaderStartOffset > 0)
42994337
{
43004338
isParsed = true;
@@ -4303,8 +4341,8 @@ private void LoadAssetPoolIntoListView()
43034341
dataEnd = $"0x{record.AssetDataEndOffset:X}";
43044342
size = $"0x{record.Size:X}";
43054343
status = !string.IsNullOrEmpty(record.AdditionalData)
4306-
? $"{record.AdditionalData} (no content parser)"
4307-
: "Located, no content parser";
4344+
? record.AdditionalData
4345+
: "Located";
43084346
}
43094347

43104348
lvi.SubItems.Add(dataStart);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using Call_of_Duty_FastFile_Editor.Models;
2+
using System.Drawing.Imaging;
3+
4+
namespace Call_of_Duty_FastFile_Editor.UI
5+
{
6+
/// <summary>
7+
/// Modal viewer for raw files whose bytes are a native image format
8+
/// (JPEG, PNG, BMP, GIF, TIFF, ICO). Unlike <see cref="ImagePreviewForm"/>
9+
/// — which decodes the IW <c>image</c> asset type and handles DXT etc. —
10+
/// this viewer just hands the bytes to <see cref="Bitmap"/> which handles
11+
/// the standard formats natively. Used for the surprisingly common case
12+
/// of `.jpg`/`.png` etc. shipped as <c>rawfile</c> assets inside FFs
13+
/// (e.g. Ghosts <c>ui_mp/ingamestore/img_store_*.jpg</c>).
14+
/// </summary>
15+
public class RawFileImageViewerForm : Form
16+
{
17+
private readonly RawFileNode _node;
18+
private readonly PictureBox _pictureBox;
19+
private readonly Label _infoLabel;
20+
private readonly Label _statusLabel;
21+
private readonly Button _saveButton;
22+
23+
/// <summary>Image extensions <see cref="Bitmap"/> can decode directly.</summary>
24+
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
25+
{
26+
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tif", ".tiff", ".ico"
27+
};
28+
29+
/// <summary>True if <paramref name="fileName"/> has an extension that
30+
/// can be decoded by <see cref="Bitmap"/> natively.</summary>
31+
public static bool IsImageRawFile(string? fileName)
32+
{
33+
if (string.IsNullOrEmpty(fileName)) return false;
34+
return SupportedExtensions.Contains(Path.GetExtension(fileName));
35+
}
36+
37+
public RawFileImageViewerForm(RawFileNode node)
38+
{
39+
_node = node;
40+
Text = $"Image Preview — {node.FileName}";
41+
Size = new Size(900, 700);
42+
StartPosition = FormStartPosition.CenterParent;
43+
MinimumSize = new Size(400, 300);
44+
45+
_infoLabel = new Label
46+
{
47+
Dock = DockStyle.Top,
48+
// Two lines of Consolas 9pt + 10px top/bottom padding = ~58.
49+
// Use 64 for comfortable headroom so descenders/wider fonts
50+
// don't clip on high-DPI displays.
51+
Height = 64,
52+
Padding = new Padding(10),
53+
BackColor = SystemColors.ControlLight,
54+
Font = new Font("Consolas", 9),
55+
};
56+
57+
var bottomPanel = new Panel { Dock = DockStyle.Bottom, Height = 35 };
58+
_statusLabel = new Label
59+
{
60+
Dock = DockStyle.Fill,
61+
Padding = new Padding(5),
62+
TextAlign = ContentAlignment.MiddleLeft,
63+
};
64+
_saveButton = new Button
65+
{
66+
Text = "Save As…",
67+
Dock = DockStyle.Right,
68+
Width = 110,
69+
Enabled = false,
70+
};
71+
_saveButton.Click += SaveButton_Click;
72+
bottomPanel.Controls.Add(_statusLabel);
73+
bottomPanel.Controls.Add(_saveButton);
74+
75+
_pictureBox = new PictureBox
76+
{
77+
Dock = DockStyle.Fill,
78+
SizeMode = PictureBoxSizeMode.Zoom,
79+
BackColor = Color.DarkGray,
80+
};
81+
82+
Controls.Add(_pictureBox);
83+
Controls.Add(bottomPanel);
84+
Controls.Add(_infoLabel);
85+
86+
LoadImage();
87+
}
88+
89+
private void LoadImage()
90+
{
91+
byte[]? bytes = _node.RawFileBytes;
92+
if (bytes == null || bytes.Length == 0)
93+
{
94+
_infoLabel.Text = $"Name: {_node.FileName}\nSize: 0 bytes";
95+
_statusLabel.Text = "No bytes available.";
96+
_statusLabel.ForeColor = Color.Red;
97+
return;
98+
}
99+
100+
try
101+
{
102+
// Copy the bytes into the MemoryStream because Bitmap keeps a
103+
// reference to the underlying stream for its lifetime — we
104+
// can't dispose the stream until the bitmap is gone. Easiest
105+
// is to keep the stream alive by attaching it to the bitmap's
106+
// Tag, but since this is a short-lived modal we just leave
107+
// the stream around for the form's lifetime.
108+
var ms = new MemoryStream(bytes, writable: false);
109+
var bitmap = new Bitmap(ms);
110+
_pictureBox.Image = bitmap;
111+
_saveButton.Enabled = true;
112+
113+
_infoLabel.Text =
114+
$"Name: {_node.FileName}\n" +
115+
$"Format: {bitmap.RawFormat} | Dimensions: {bitmap.Width} × {bitmap.Height} | Bytes: {bytes.Length:N0}";
116+
_statusLabel.Text = "Image decoded successfully.";
117+
_statusLabel.ForeColor = Color.DarkGreen;
118+
}
119+
catch (Exception ex)
120+
{
121+
_infoLabel.Text = $"Name: {_node.FileName}\nSize: {bytes.Length:N0} bytes";
122+
_statusLabel.Text = $"Could not decode as image: {ex.Message}";
123+
_statusLabel.ForeColor = Color.Red;
124+
}
125+
}
126+
127+
/// <summary>
128+
/// Save As writes the <b>original</b> bytes verbatim (preserves the
129+
/// FF-shipped format byte-for-byte). If the user picks a different
130+
/// extension we re-encode through <see cref="Bitmap.Save(string, ImageFormat)"/>.
131+
/// </summary>
132+
private void SaveButton_Click(object? sender, EventArgs e)
133+
{
134+
using var dlg = new SaveFileDialog
135+
{
136+
Title = "Save Image",
137+
FileName = Path.GetFileName(_node.FileName),
138+
Filter = "Original Bytes|*.*|PNG|*.png|JPEG|*.jpg|Bitmap|*.bmp|GIF|*.gif",
139+
DefaultExt = Path.GetExtension(_node.FileName)?.TrimStart('.') ?? "png",
140+
};
141+
if (dlg.ShowDialog(this) != DialogResult.OK) return;
142+
143+
try
144+
{
145+
string outExt = Path.GetExtension(dlg.FileName).ToLowerInvariant();
146+
string srcExt = Path.GetExtension(_node.FileName).ToLowerInvariant();
147+
// If the output extension matches the source, dump bytes verbatim.
148+
// Otherwise re-encode through the bitmap.
149+
if (outExt == srcExt || dlg.FilterIndex == 1 /* "Original Bytes" */)
150+
{
151+
File.WriteAllBytes(dlg.FileName, _node.RawFileBytes ?? Array.Empty<byte>());
152+
}
153+
else
154+
{
155+
ImageFormat fmt = outExt switch
156+
{
157+
".jpg" or ".jpeg" => ImageFormat.Jpeg,
158+
".bmp" => ImageFormat.Bmp,
159+
".gif" => ImageFormat.Gif,
160+
_ => ImageFormat.Png,
161+
};
162+
_pictureBox.Image!.Save(dlg.FileName, fmt);
163+
}
164+
_statusLabel.Text = $"Saved to: {dlg.FileName}";
165+
_statusLabel.ForeColor = Color.DarkGreen;
166+
}
167+
catch (Exception ex)
168+
{
169+
MessageBox.Show($"Failed to save image: {ex.Message}", "Save Error",
170+
MessageBoxButtons.OK, MessageBoxIcon.Error);
171+
}
172+
}
173+
174+
protected override void Dispose(bool disposing)
175+
{
176+
if (disposing)
177+
{
178+
_pictureBox?.Image?.Dispose();
179+
}
180+
base.Dispose(disposing);
181+
}
182+
}
183+
}

Call of Duty FastFile Editor/ZoneParsers/GhostsAssetWalker.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ public static WalkResult Walk(byte[] zoneData, List<ZoneAssetRecord>? records, i
7676
record.AdditionalData = $"Ghosts pool-walked ({(h.IsLong ? "long" : "short")} header)";
7777
records[i] = record;
7878

79-
if (record.AssetType_Ghosts == GhostsAssetTypePS3.rawfile)
79+
// Emit a RawFileNode for any wrapped body the editor can
80+
// surface to the user. rawfile entries are the obvious case;
81+
// image entries paired by name-aware pairing (JPGs etc.) also
82+
// become RawFileNodes so the Raw Files tab + image viewer
83+
// pick them up. scriptfile / mptype / aitype bodies are left
84+
// metadata-only — the editor doesn't currently expose them.
85+
if (record.AssetType_Ghosts == GhostsAssetTypePS3.rawfile
86+
|| record.AssetType_Ghosts == GhostsAssetTypePS3.image)
8087
result.RawFileNodes.Add(BuildWrappedNode(zoneData, h));
8188
}
8289

@@ -106,7 +113,35 @@ public static WalkResult Walk(byte[] zoneData, List<ZoneAssetRecord>? records, i
106113
result.RawFileNodes.Add(BuildLuaFileNode(zoneData, lua));
107114
}
108115

109-
result.Resolved = pairing.PairedCount + luaPaired;
116+
// Pair image pool entries with scanned GfxImage structs — flat-binary
117+
// bodies that have no signature, so the scanner finds them by their
118+
// inline name preceded by a FFFFFFFF NamePtr placeholder. Image
119+
// entries already paired with a zlib-wrapped JPG body (by the
120+
// name-aware wrapped-header pairing) keep that pairing; the GfxImage
121+
// pass only fills in image records that are still unresolved.
122+
var gfxImages = IW6GfxImageScanner.Locate(zoneData);
123+
int gfxIdx = 0, gfxPaired = 0;
124+
for (int i = 0; i < records.Count && gfxIdx < gfxImages.Count; i++)
125+
{
126+
if (records[i].AssetType_Ghosts != GhostsAssetTypePS3.image) continue;
127+
if (!string.IsNullOrEmpty(records[i].Name)) continue; // already paired (JPG)
128+
var img = gfxImages[gfxIdx++];
129+
130+
var record = records[i];
131+
record.HeaderStartOffset = img.StructOffset;
132+
record.HeaderEndOffset = img.NameOffset;
133+
record.AssetDataStartPosition = img.StructOffset;
134+
record.AssetDataEndOffset = img.NameOffset + img.Name.Length;
135+
record.AssetRecordEndOffset = img.NameOffset + img.Name.Length;
136+
record.Name = img.Name;
137+
record.Size = IW6GfxImageScanner.StructSize;
138+
record.AdditionalData =
139+
$"GfxImage {img.Width}×{img.Height} ({IW6GfxImageScanner.SemanticName(img.Semantic)} / {IW6GfxImageScanner.MapTypeName(img.MapType)})";
140+
records[i] = record;
141+
gfxPaired++;
142+
}
143+
144+
result.Resolved = pairing.PairedCount + luaPaired + gfxPaired;
110145
result.Unresolved = Math.Max(0, records.Count - result.Resolved);
111146
result.UnpairedHeaders = pairing.UnpairedHeaders + Math.Max(0, luaFiles.Count - luaIdx);
112147

0 commit comments

Comments
 (0)