Skip to content

Commit 0a37c81

Browse files
committed
Detect Ghosts luafiles; add inspector/tests #65
Add detection and handling of Ghosts 'luafile' assets alongside the existing zlib-wrapped headers. GhostsAssetWalker now locates luafiles, pairs luafile pool entries in a separate pass, and surfaces luafiles as rawfile-like nodes using a new BuildLuaFileNode; wrapped asset node builder was renamed to BuildWrappedNode. GhostsZoneLayout gains a GhostsLuaFile struct, a LocateAllLuaFiles scanner (flat 16-byte header + Lua 5.1 signature), and a permissive pool-pointer check to accept IW6-style 0x40-flagged pointers. Introduce LuaBytecodeInspector to parse the Lua 5.1 header, extract printable strings, and format a summary used in the viewer. Tests updated/added to cover pool parsing, luafile discovery, and the bytecode inspector behavior.
1 parent f55819b commit 0a37c81

5 files changed

Lines changed: 633 additions & 23 deletions

File tree

Call of Duty FastFile Editor/ZoneParsers/GhostsAssetWalker.cs

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,35 @@ public static WalkResult Walk(byte[] zoneData, List<ZoneAssetRecord>? records, i
2828
var result = new WalkResult();
2929
if (zoneData == null || zoneData.Length == 0) return result;
3030

31-
var headers = GhostsZoneLayout.LocateAllHeaders(zoneData, scanStart < 0 ? 0 : scanStart);
31+
int scan = scanStart < 0 ? 0 : scanStart;
32+
var headers = GhostsZoneLayout.LocateAllHeaders(zoneData, scan);
33+
var luaFiles = GhostsZoneLayout.LocateAllLuaFiles(zoneData, scan);
3234

3335
// Base zones: no pool to pair against. Surface every located header
34-
// as a rawfile (per docs/Ghosts_FastFile_Format.md, "almost all"
35-
// wrapped assets in base zones are rawfiles).
36+
// (wrapped + luafile) as a rawfile-like node so the editor's rawfile
37+
// tree still populates. Per docs/Ghosts_FastFile_Format.md, "almost
38+
// all" wrapped assets in base zones are rawfiles.
3639
if (records == null || records.Count == 0)
3740
{
3841
foreach (var h in headers)
39-
result.RawFileNodes.Add(BuildRawFileNode(zoneData, h));
40-
result.UnpairedHeaders = headers.Count;
41-
Debug.WriteLine($"[GhostsAssetWalker] No pool — surfaced {headers.Count} located headers as rawfiles");
42+
result.RawFileNodes.Add(BuildWrappedNode(zoneData, h));
43+
foreach (var lua in luaFiles)
44+
result.RawFileNodes.Add(BuildLuaFileNode(zoneData, lua));
45+
result.UnpairedHeaders = headers.Count + luaFiles.Count;
46+
Debug.WriteLine($"[GhostsAssetWalker] No pool — surfaced {headers.Count} wrapped + {luaFiles.Count} luafile headers as rawfiles");
4247
return result;
4348
}
4449

45-
// Lift to library DTOs for pairing.
50+
// Pair wrapped types (rawfile/scriptfile/mptype/aitype) with the
51+
// located zlib-wrapped headers.
4652
var poolDtos = new List<GhostsPoolEntry>(records.Count);
4753
foreach (var r in records)
4854
{
4955
poolDtos.Add(new GhostsPoolEntry(
5056
recordOffset: r.AssetPoolRecordOffset,
5157
type: r.AssetType_Ghosts,
52-
pointerKind: GhostsPointerKind.Placeholder)); // pointer kind isn't used during pairing
58+
pointerKind: GhostsPointerKind.Placeholder));
5359
}
54-
5560
var pairing = GhostsZoneLayout.PairPoolWithHeaders(poolDtos, headers);
5661

5762
for (int i = 0; i < records.Count; i++)
@@ -72,18 +77,44 @@ public static WalkResult Walk(byte[] zoneData, List<ZoneAssetRecord>? records, i
7277
records[i] = record;
7378

7479
if (record.AssetType_Ghosts == GhostsAssetTypePS3.rawfile)
75-
result.RawFileNodes.Add(BuildRawFileNode(zoneData, h));
80+
result.RawFileNodes.Add(BuildWrappedNode(zoneData, h));
81+
}
82+
83+
// Pair luafile pool entries with located luafiles — separate pass
84+
// because the on-disk format is different (flat 16-byte header, no
85+
// zlib wrapper). Same positional rule: i-th luafile pool entry ↔
86+
// i-th located luafile body.
87+
int luaIdx = 0;
88+
int luaPaired = 0;
89+
for (int i = 0; i < records.Count && luaIdx < luaFiles.Count; i++)
90+
{
91+
if (records[i].AssetType_Ghosts != GhostsAssetTypePS3.luafile) continue;
92+
var lua = luaFiles[luaIdx++];
93+
var record = records[i];
94+
95+
record.HeaderStartOffset = lua.HeaderOffset;
96+
record.HeaderEndOffset = lua.BodyStart;
97+
record.AssetDataStartPosition = lua.BodyStart;
98+
record.AssetDataEndOffset = lua.BodyEnd;
99+
record.AssetRecordEndOffset = lua.BodyEnd;
100+
record.Name = lua.Name;
101+
record.Size = lua.ByteCodeLen;
102+
record.AdditionalData = "Ghosts luafile (flat header)";
103+
records[i] = record;
104+
luaPaired++;
105+
106+
result.RawFileNodes.Add(BuildLuaFileNode(zoneData, lua));
76107
}
77108

78-
result.Resolved = pairing.PairedCount;
79-
result.Unresolved = Math.Max(0, records.Count - pairing.PairedCount);
80-
result.UnpairedHeaders = pairing.UnpairedHeaders;
109+
result.Resolved = pairing.PairedCount + luaPaired;
110+
result.Unresolved = Math.Max(0, records.Count - result.Resolved);
111+
result.UnpairedHeaders = pairing.UnpairedHeaders + Math.Max(0, luaFiles.Count - luaIdx);
81112

82-
Debug.WriteLine($"[GhostsAssetWalker] Paired {result.Resolved}/{records.Count} pool entries with {headers.Count} located headers ({result.UnpairedHeaders} unpaired); {result.RawFileNodes.Count} rawfiles");
113+
Debug.WriteLine($"[GhostsAssetWalker] Paired {pairing.PairedCount} wrapped + {luaPaired} luafile pool entries; {result.RawFileNodes.Count} rawfile-like nodes");
83114
return result;
84115
}
85116

86-
private static RawFileNode BuildRawFileNode(byte[] zone, GhostsAssetHeader h)
117+
private static RawFileNode BuildWrappedNode(byte[] zone, GhostsAssetHeader h)
87118
{
88119
byte[] body = new byte[h.DecompressedLen];
89120
Buffer.BlockCopy(zone, h.BodyStart, body, 0, h.DecompressedLen);
@@ -102,6 +133,30 @@ private static RawFileNode BuildRawFileNode(byte[] zone, GhostsAssetHeader h)
102133
};
103134
}
104135

136+
private static RawFileNode BuildLuaFileNode(byte[] zone, GhostsLuaFile lua)
137+
{
138+
byte[] body = new byte[lua.ByteCodeLen];
139+
Buffer.BlockCopy(zone, lua.BodyStart, body, 0, lua.ByteCodeLen);
140+
// The body is Lua 5.1 bytecode (compiled, not source). Surface a
141+
// header + extracted-strings summary in the text viewer so the
142+
// user gets useful signal about each file's content; the original
143+
// .lua source isn't recoverable without a decompiler.
144+
var summary = LuaBytecodeInspector.Inspect(body);
145+
string content = LuaBytecodeInspector.FormatSummaryText(lua.Name, summary);
146+
return new RawFileNode
147+
{
148+
FileName = lua.Name,
149+
StartOfFileHeader = lua.HeaderOffset,
150+
HeaderSize = lua.BodyStart - lua.HeaderOffset,
151+
MaxSize = lua.ByteCodeLen,
152+
CompressedSize = lua.ByteCodeLen,
153+
IsCompressed = false,
154+
RawFileBytes = body,
155+
RawFileContent = content,
156+
AdditionalData = "Ghosts luafile (Lua 5.1 bytecode)",
157+
};
158+
}
159+
105160
private static bool LooksTextual(byte[] body)
106161
{
107162
if (body.Length == 0) return true;

FastFileCLI.Tests/GhostsZoneLayoutTests.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text;
12
using FastFileLib;
23
using FastFileLib.GameDefinitions;
34
using Xunit;
@@ -302,4 +303,141 @@ public void IsWrappedType_OnlyKnownWrappedTypes()
302303
Assert.False(GhostsZoneLayout.IsWrappedType(GhostsAssetTypePS3.techset));
303304
Assert.False(GhostsZoneLayout.IsWrappedType(GhostsAssetTypePS3.weapon));
304305
}
306+
307+
// =========================================================
308+
// Permissive pointer convention (0x40-flagged)
309+
// =========================================================
310+
311+
[Fact]
312+
public void WalkPool_AcceptsZero40FlaggedPointer()
313+
{
314+
// IW6 patch_ui_mp.zone has pool entries like [40 1F DF 85][00 00 00 05]
315+
// (material ptr with the 0x40000000 flag bit set). Verify they parse.
316+
byte[] flaggedPtr = { 0x40, 0x1F, 0xDF, 0x85 };
317+
byte[] zone = Concat(
318+
Header(0, 2),
319+
PoolRecord(0x05, flaggedPtr), // material with 0x40-flagged ptr
320+
PoolRecord(0x09, FF4), // image with placeholder
321+
new byte[256]);
322+
323+
var entries = GhostsZoneLayout.ParsePool(zone, out int poolStart, out _);
324+
Assert.Equal(0x38, poolStart);
325+
Assert.Equal(2, entries.Count);
326+
Assert.Equal(GhostsPointerKind.Resolved, entries[0].PointerKind);
327+
Assert.Equal(GhostsAssetTypePS3.material, entries[0].Type);
328+
}
329+
330+
[Fact]
331+
public void WalkPool_HeaderCountCapsAtAssetCount()
332+
{
333+
// Make the bytes past `assetCount` entries also look like a pool entry.
334+
// The header-driven cap should stop the walk at the declared count.
335+
byte[] zone = Concat(
336+
Header(0, 2),
337+
PoolRecord(0x28, FF4),
338+
PoolRecord(0x29, Zero4),
339+
PoolRecord(0x05, FF4), // extra would be walkable but capped
340+
new byte[256]);
341+
342+
var entries = GhostsZoneLayout.ParsePool(zone, out _, out _);
343+
Assert.Equal(2, entries.Count);
344+
}
345+
346+
// =========================================================
347+
// Luafile scan
348+
// =========================================================
349+
350+
private static byte[] LuaFile(string name, byte[] body)
351+
{
352+
var nameBytes = Encoding.ASCII.GetBytes(name);
353+
var nullTerm = new byte[] { 0 };
354+
var unk = Be32(0x02000000); // observed-fixed value in real zones
355+
return Concat(FF4, Be32((uint)body.Length), unk, FF4, nameBytes, nullTerm, body);
356+
}
357+
358+
private static byte[] LuaBytecode(int totalSize)
359+
{
360+
var b = new byte[totalSize];
361+
b[0] = 0x1B; b[1] = (byte)'L'; b[2] = (byte)'u'; b[3] = (byte)'a'; b[4] = 0x51;
362+
return b;
363+
}
364+
365+
[Fact]
366+
public void LocateAllLuaFiles_FindsSingleEntry()
367+
{
368+
byte[] zone = Concat(new byte[0x100], LuaFile("ui/main.lua", LuaBytecode(128)), new byte[256]);
369+
370+
var lua = GhostsZoneLayout.LocateAllLuaFiles(zone, 0x100);
371+
372+
Assert.Single(lua);
373+
Assert.Equal("ui/main.lua", lua[0].Name);
374+
Assert.Equal(128, lua[0].ByteCodeLen);
375+
Assert.Equal(0x100, lua[0].HeaderOffset);
376+
}
377+
378+
[Fact]
379+
public void LocateAllLuaFiles_FindsConsecutiveEntries()
380+
{
381+
// Three back-to-back luafiles. Stride should walk from one to the next.
382+
byte[] zone = Concat(
383+
new byte[0x40],
384+
LuaFile("a.lua", LuaBytecode(64)),
385+
LuaFile("ui/b.lua", LuaBytecode(96)),
386+
LuaFile("c.lua", LuaBytecode(48)),
387+
new byte[16]);
388+
389+
var lua = GhostsZoneLayout.LocateAllLuaFiles(zone, 0x40);
390+
391+
Assert.Equal(3, lua.Count);
392+
Assert.Equal("a.lua", lua[0].Name);
393+
Assert.Equal("ui/b.lua", lua[1].Name);
394+
Assert.Equal("c.lua", lua[2].Name);
395+
}
396+
397+
[Fact]
398+
public void LocateAllLuaFiles_RejectsNonLuaSuffix()
399+
{
400+
// The 16-byte header pattern looks like luafile but the name doesn't
401+
// end in .lua, so it must be rejected (would otherwise collide with
402+
// other flat-header asset types in the same zone).
403+
byte[] zone = Concat(new byte[0x40], LuaFile("ui/main.txt", LuaBytecode(64)), new byte[256]);
404+
405+
var lua = GhostsZoneLayout.LocateAllLuaFiles(zone, 0x40);
406+
407+
Assert.Empty(lua);
408+
}
409+
410+
[Fact]
411+
public void LocateAllLuaFiles_RejectsMissingLuaSignature()
412+
{
413+
// Header + name look fine but the body doesn't start with \x1B LuaQ.
414+
// The signature check is the primary defense against false positives.
415+
byte[] nameBytes = Encoding.ASCII.GetBytes("ui/main.lua");
416+
byte[] notLua = new byte[64]; // all zeros, no Lua magic
417+
byte[] entry = Concat(FF4, Be32(64), Be32(0x02000000), FF4, nameBytes, new byte[] { 0 }, notLua);
418+
byte[] zone = Concat(new byte[0x40], entry, new byte[256]);
419+
420+
var lua = GhostsZoneLayout.LocateAllLuaFiles(zone, 0x40);
421+
422+
Assert.Empty(lua);
423+
}
424+
425+
[Fact]
426+
public void LocateAllLuaFiles_SkipsPastNonMatchBytes()
427+
{
428+
// Pad with random bytes between two luafiles. The scan must byte-walk
429+
// through the gap and recover the second entry.
430+
byte[] zone = Concat(
431+
new byte[0x40],
432+
LuaFile("a.lua", LuaBytecode(32)),
433+
new byte[] { 0xAB, 0xCD, 0xEF, 0x12, 0x34 }, // 5 bytes of junk
434+
LuaFile("b.lua", LuaBytecode(48)),
435+
new byte[16]);
436+
437+
var lua = GhostsZoneLayout.LocateAllLuaFiles(zone, 0x40);
438+
439+
Assert.Equal(2, lua.Count);
440+
Assert.Equal("a.lua", lua[0].Name);
441+
Assert.Equal("b.lua", lua[1].Name);
442+
}
305443
}

0 commit comments

Comments
 (0)