Skip to content

Reconstruct Recoil source projects: resources, build scripts and sound banks - #17

Closed
Vorta wants to merge 1 commit into
mainfrom
feat/source-project
Closed

Vorta wants to merge 1 commit into
mainfrom
feat/source-project

Conversation

@Vorta

@Vorta Vorta commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Problem and resulting behavior

RECOIL's shipped ZBD files are build outputs. The studio built them with a gamegen tool from a source tree (data\ beside a folder of .gs/.gw scripts), repackaging shared textures, models, animations and resources for every mission. This PR is the first part of reconstructing that source tree so zStudio can work on sources like a world editor and pack the game files back.

Tools → Reconstruct source project… (MCP source_reconstruct) rebuilds a project from a shipped data folder:

  • ZAR resources (zrdr.zbd, mN\zrdr.zbd) become text .zrd files in the directories their compiler recorded. Every member stores the temporary file it was compiled from, for example D:\battlesportdev\data\m1\zrdr\envmodels\fueE3B0.TMP, so 84 original directories are recovered, including subfolders (envmodels, vtol, semi, explosns…) that member names do not carry.
  • interp.zbd becomes the original build scripts, gamegen\*.gs and gamegen\support\*.gw, with their recorded modification times.
  • Sound banks become data\common\sounds\*.wav from the high-quality bank; medium/low variants are stored and used while their source is unchanged.
  • GameZ, anim.zbd, texture packs and image.zbd are carried verbatim until their sources are reconstructed in follow-up PRs.

Every output is verified to pack back byte-identically before it is accepted. All 58 files of the 1999 corpus and the 1998 corpus reconstruct and pack back exactly.

Tools → Pack ZBD files… / Verify source project (MCP source_pack) builds every game file from the project on disk. Outputs are staged and reopened through the shared readers, and nothing is written if any output fails. Each output is reported as identical, changed (with the edited sources) or failed. source_status summarizes a project.

Formats

  • zReader text: the original compiler syntax did not survive (the retail engine only reads compiled data), so zStudio defines a lossless syntax. It has bit-exact floats (f32:XXXXXXXX for NaN payloads), keeps the int/float distinction the engine enforces, supports Latin-1 escapes and preserves dangling keys and order. Parse errors name the line.
  • Gamegen scripts: these use the engine's own tokenizer (CZInterp::TokenizeLine, retail 0x4C13C0), including comma-only empty tokens.
  • Residue: record order, uninitialized fields, padding and original timestamps live in .zstudio layouts and apply only while a source's content hash matches.

Text .zrd sources open in the shared ZRD editor and save as text; importing one into an archive compiles it. Scripts open as token text. .zstudio metadata stays out of Files/Search.

Research basis

A read-only study of the reconstructed engine, both corpora and the retail exe established what survives: compile paths, the complete gamegen scripts in interp.zbd, anim.zbd dependency stamps (448 sources with times) and .flt/.tif names in GameZ. It also established what is lost: the original text syntaxes, OpenFlight attributes and full-resolution textures. Details are in docs/source-project.md and docs/status.md.

Next parts

Texture packs and image.zbd with .tif sources, anim.zbd with its .zrd/.zan sources, and GameZ worlds with OpenFlight .flt sources and a new GameZ writer.

Validation

  • Release build: 0 warnings.
  • 670/670 tests with RECOIL1999 and MW3 corpora, including a corpus test that reconstructs and byte-verifies every shipped file. Core 499/499 with RECOIL1998.
  • New unit coverage: zRD text edge cases (integer extremes, −0, subnormals, NaN payloads, all Latin-1 bytes, number-like strings, deep nesting) and engine tokenizer cases. Synthetic end-to-end tests cover edits, stored-variant refusal, all-or-nothing packing, separate-folder/protected/link rules and hostile manifest paths.
  • A named-pipe MCP check covers reconstruct/open, status, text .zrd editing and saving, unsaved-edit refusal, verify and pack.
  • Discovery catalog regenerated (85 tools); parity passes. Portable package rebuilt.

- Rebuild the original build tree: ZAR resources become text .zrd files in the folders their compiler recorded (84 directories, including subfolders), interp.zbd becomes the gamegen *.gs and support\*.gw scripts with their original times, and sound banks become data\common\sounds WAVs with stored lower-quality variants.
- Keep record order, residue bytes and timestamps in .zstudio layouts tied to source hashes. Accept an output only after it packs back byte-identically; other families (GameZ, anim, texture packs, image.zbd) are carried verbatim for now. All 58 files of the 1999 corpus, and the 1998 corpus, reconstruct and pack back exactly.
- Define a lossless zReader text syntax (bit-exact floats, Latin-1 strings, dangling keys) and engine-exact gamegen script tokenization, with line-numbered parse errors.
- Pack into a separate folder with staged, re-parsed, all-or-nothing writes, or verify without writing; report identical, changed and failed outputs.
- Open text .zrd sources in the ZRD editor and save them as text, compile them on archive import, and show scripts as token text.
- Add Tools menu commands and the source_reconstruct, source_pack and source_status MCP tools (85 tools), with documentation and tests.
@Vorta

Vorta commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-30T12:35:33.739899Z 55a97ca Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55a97caf26

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{
string target = SourceProject.Resolve(destination, result.Path);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Move(SourceProject.Resolve(staging, result.Path), target, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Publish packed outputs atomically

When repacking an existing destination, a failure on any later File.Move—for example because a target is locked or the disk fills—or while writing the marker occurs after earlier targets and stale files have already been replaced or deleted. The operation then throws despite leaving a mixed old/new pack and potentially destroying the last known-good output; retain rollback copies or publish the completed staging tree with an atomic swap.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment on lines +88 to +89
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Move(SourceProject.Resolve(staging, result.Path), target, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject nested links before publishing outputs

When a previously packed destination contains a junction/reparse point in an output subdirectory, RejectLinks(destination) only inspects the destination and its ancestors, so this directory creation and move follow the nested link and can overwrite a file outside the selected pack folder. Validate every existing target parent immediately before publication without following reparse points, including the stale-output deletion path.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment on lines +111 to +112
doc.SourceSyntax = "gamegen-script"; string text = System.Text.Encoding.Latin1.GetString(bytes);
var lines = Sources.GameGenScriptText.Tokenize(text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound source text before materializing it

When opening a large .gs/.gw source—up to the global 512 MiB document limit—this decodes the entire byte array to UTF-16, after which Tokenize creates replacement, split, and token strings before enforcing any aggregate bound; the .zrd sibling similarly calls Encoding.Latin1.GetString before its node limit. A comment-heavy or single-row file can therefore exhaust memory during ordinary GUI or MCP opening instead of producing a diagnostic, so impose a byte/character limit before conversion or parse incrementally.

AGENTS.md reference: AGENTS.md:L13-L13

Useful? React with 👍 / 👎.

Comment on lines +81 to +83
var previous = JsonSerializer.Deserialize<PackMarker>(await File.ReadAllBytesAsync(marker, token), SourceProject.Json);
foreach (var old in previous?.Outputs ?? [])
if (!results.Any(r => r.Path.Equals(old.Path, StringComparison.OrdinalIgnoreCase))) { string stale = SourceProject.Resolve(destination, old.Path); if (File.Exists(stale)) File.Delete(stale); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse pack folders owned by another project

When the selected destination was previously packed from a different source project, the mere presence of zstudio-pack.json allows reuse, and the deserialized Origin is never compared with manifest.Origin.Fingerprint. Publication then deletes every old output absent from the current project and overwrites overlaps, so choosing the wrong prior pack folder silently destroys another project's packed files; reject an origin mismatch or require an empty destination.

Useful? React with 👍 / 👎.

token.ThrowIfCancellationRequested(); var output = manifest.Outputs[i]; progress?.Report(new(i, manifest.Outputs.Count, output.Path));
try
{
var built = await Task.Run(() => Build(projectRoot, output, token), token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Freeze source files across the complete pack

When a shared source changes between output iterations, each Build rereads live disk state independently, so the staged set need not correspond to any project revision. For example, soundsh.zbd can capture an edited WAV, then if that WAV is restored before soundsm.zbd is built, the derived bank accepts the original hash and cached variant; every output reopens and the incoherent set is published. Snapshot source bytes/hashes for the whole run or recheck them all before publication.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

string prefix = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)) + Path.DirectorySeparatorChar;
if (ViewModel.Documents.FirstOrDefault(d => d.IsDirty && Path.GetFullPath(d.Path).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) is { } dirty)
throw new StudioCommandException("unsaved_changes", $"Save or discard the edits to {Path.GetFileName(dirty.Path)} before packing.");
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); operation = cancellation; CancelOperationItem.IsEnabled = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel source packing when the workspace is replaced

When the user opens another folder while packing or verification is running, this token is linked only to the command token; OpenRootAsync replaces the workspace without canceling operation. The old project's pack can therefore continue publishing after its owning workspace and dirty-document check are obsolete, and its completion status is posted into the new workspace. Link the operation to the captured workspace lifetime or cancel it during root replacement.

AGENTS.md reference: AGENTS.md:L41-L41

Useful? React with 👍 / 👎.

Comment on lines +23 to +24
Directory.CreateDirectory(projectRoot);
Context context = new(projectRoot, token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove partial projects after canceled reconstruction

When reconstruction is canceled or an I/O error occurs after this directory is created, files already written by prior iterations are left behind without a completed manifest. A retry with the same destination then fails the nonempty-folder check, forcing the user to identify and delete partial generated data manually; stage reconstruction in a temporary sibling or track and remove the operation's files on failure.

Useful? React with 👍 / 👎.

Comment on lines +19 to +22
var files = Corpus(corpusRoot);
// The recovered build layout is RECOIL's; MechWarrior 3 data uses other formats and folders.
if (files.FirstOrDefault(f => FormatRegistry.Probe(f.Path) is { Family: FormatFamily.GameZ, Version: 27 } or { Family: FormatFamily.Animation, Version: 39 }) is { Path: not null } mw3)
throw new InvalidDataException($"{mw3.Relative} is MechWarrior 3 data; source reconstruction supports RECOIL.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject folders that are not RECOIL corpora

When the selected source is empty or contains only arbitrary files, this code merely finds no MW3 header and proceeds to create a successful project—an empty folder yields zero outputs, while unrelated files become passthrough outputs—and the UI reports that every output packed byte-identically. Require positive RECOIL evidence such as the expected prepared-script/archive structure before creating the destination so a wrong folder is not presented as a reconstructed RECOIL source tree.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment on lines +65 to +68
if (extension.Equals(".zrd", StringComparison.OrdinalIgnoreCase) && Sources.ZrdText.LooksLikeText(prefix))
return new(FormatFamily.Zrd, null, Recognition.Supported, SourceZrdDescription);
if (extension.Equals(".gw", StringComparison.OrdinalIgnoreCase) || extension.Equals(".gs", StringComparison.OrdinalIgnoreCase))
return new(FormatFamily.Scripts, null, Recognition.Supported, SourceScriptDescription);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe archive structure before source extensions

When a valid ZAR is named .gs/.gw, or named .zrd with a first payload word outside 1–4, these extension branches return before the definitive archive-footer check below. The same bytes that were previously recognized as an archive are consequently tokenized or parsed as source text and fail or display bogus content; move the structural ZAR probe ahead of filename-based source recognition.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Comment on lines +87 to +90
project = root, manifest.Game, manifest.Version, origin = manifest.Origin,
families = manifest.Outputs.GroupBy(o => o.Family).ToDictionary(g => g.Key, g => g.Count()),
notes = manifest.Notes.Take(32).Select(n => Bounded(n, 512)).ToArray(), noteCount = manifest.Notes.Count, notesTruncated = manifest.Notes.Count > 32,
outputs = Page(manifest.Outputs, a, o => o.Path, o => new { o.Path, o.Family, o.Bytes, o.Sha256 }).Data

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound every source-status manifest field

When a project has a large but permitted manifest field—for example a multi-megabyte origin.name, family, or output path—this response passes it through verbatim even though only notes and row counts are bounded. A single row can therefore allocate or emit most of the 64 MiB manifest through MCP before any final protocol-size rejection; truncate each returned string with disclosed original lengths, including the corresponding PackResult path fields.

AGENTS.md reference: AGENTS.md:L13-L13

Useful? React with 👍 / 👎.

@Vorta

Vorta commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Closing: the source-project design is changing. Projects will no longer be byte-identical, and they will keep no extra metadata. Models become glTF with PNG textures, lower-quality sound and texture variants are regenerated when packing, and anim.zbd is compiled from its .zrd/.zan sources. The work will return in a new PR after local testing.

@Vorta Vorta closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant