Skip to content

Add a command line interface - #386

Open
playday3008 wants to merge 45 commits into
dexyfex:masterfrom
playday3008:cli
Open

Add a command line interface#386
playday3008 wants to merge 45 commits into
dexyfex:masterfrom
playday3008:cli

Conversation

@playday3008

@playday3008 playday3008 commented Sep 2, 2026

Copy link
Copy Markdown

Adds CodeWalker.Cli, a console front end over CodeWalker.Core, so archives
can be worked with from a script or a machine without a desktop.

Twelve commands: extract, list, hash, tree, gen9, pack, diff, export, stat,
search, validate, inspect. Each takes --json and writes one object to
stdout; --help carries the JSON fields and exit codes per command.

Targets net48, net8.0 and net10.0. It has its own .editorconfig and
analyzer settings and builds warning-free under -warnaserror. Nothing
outside the new project changes style or tooling.

Shared code needed four fixes, all of which affect the GUI as well:

  • Paths were concatenated with a literal backslash, and the executable was
    probed for as gta5.exe when the shipped file is GTA5.exe, so a
    case-sensitive filesystem rejects a valid install.
  • RpfFile.CreateNew looked for a colon to decide a path was absolute.
  • ExtractFileResource permanently subtracted 16 from entry.FileSize
    whenever a resource failed to decompress, so repeated extraction of the
    same file returned less each time.
  • ResourceDataReader threw an ArgumentException naming no file when the
    extracted buffer was shorter than the page flags require.

533 tests, none of which need a GTA V install. CI covers all three
frameworks on Windows, net8.0 and net10.0 on Linux, formatting, and a full
solution build so a Core change cannot quietly break the WinForms projects.

Also run against a real Enhanced install. The legacy, non-Gen9 paths are
untouched by this branch but untested: no legacy install available here.

The installation is probed for "gta5.exe" and "gta5_enhanced.exe", but
the files Rockstar ships are named GTA5.exe and GTA5_Enhanced.exe. On
Windows that difference does not exist; anywhere else the probe fails
and the folder is rejected as not being a GTA V installation, so the
key loader, the folder picker and the ModManager settings all refuse a
perfectly good install.

The same call sites built their paths by concatenating a literal
backslash, which is not a separator off Windows either. They use
Path.Combine.

Two more in CodeWalker.Core did the same. RpfFile.CreateNew decided
whether a path was already absolute by looking for a colon, so an
absolute output path was treated as relative and 'pack -o /a/b/out.rpf'
wrote a file named 'b\out.rpf' inside /a while reporting success.
ExtractScripts joined its output paths the same way, and the
Gen9Converter folder walk appended a backslash to normalize a folder.
Path is an instance property on RpfFile, so System.IO.Path is spelled
out at those call sites.

Behaviour is unchanged on Windows.
CodeWalker is a WinForms application, so everything it knows about RPF
archives is only reachable through a GUI on Windows. CodeWalker.Core
already builds on its own; this is a console front end over it.

Three commands to start: list, extract and hash. Every one of them takes
--rpf and --exe, since reading an archive needs the encryption keys out
of the installation, and every one can emit JSON instead of text so the
output is scriptable.

Targets net48, net8.0 and net10.0. net48 is what the rest of the
solution builds against, and the modern targets are what this is
actually meant to run on.

The three handlers each opened the archive, loaded the keys, walked the
entries and reported errors their own way. RpfService and
RpfCommandOptions hold that once, so a handler is left with the part
that is specific to it.
tree prints the archive as a directory tree, gen9 converts an archive to
the Enhanced resource layout, pack builds an archive from a folder, and
diff reports what changed between two archives.

diff takes --si so the sizes it prints can use SI units like the rest,
pack takes --force to overwrite an existing archive, and extract takes
--no-overwrite to leave existing output alone. hash's JSON reports the
signed and unsigned forms of each hash rather than only the unsigned
one, since both spellings turn up in game files.
Every handler wrote out its own error result record and its own
"print the message, pick an exit code" block, all of them the same
except for the result type. The result records share a BaseResult, and
RpfService.ReportError does the printing, so a failure path is one call.

The glob translation only understood * and ?, and mapped * to '.*', so
'*.ytd' matched across directory separators and there was no way to say
"any depth". * and ? stop at a separator now, ** crosses them, and '**/'
matches zero or more directory segments.

extract counted a file it had skipped under --no-overwrite as extracted.
export turns game files into formats other tools can read: xml for
metadata, textures for .ytd dictionaries as DDS, audio for .awc streams,
and text for .gxt2 localization. Each subcommand takes an alias, so
'export t' and 'export ytd' both reach textures.

The four subcommands differ only in how they turn one entry into output
files. Everything around that -- validating the arguments, opening the
archive, collecting the entries a filter selects, running them across
threads, aggregating the results and reporting them -- is ExportService,
and a subcommand supplies a delegate.

CommonOptions holds the options that are not specific to reading an
archive, so the option records stop repeating each other.
--threads accepted zero and negative values and then passed them to
MaxDegreeOfParallelism, where anything below -1 throws. It is validated
at parse time now, so the error names the option instead of surfacing as
an exception from inside the loop.

export ignored --no-overwrite: the flag was defined but never reached
the code that writes the output files, so it overwrote regardless. Each
exporter takes it and skips the files that already exist.

The text exporter dereferenced the parsed .gxt2 without checking that it
parsed, so a file it could not read took the process down instead of
being reported as unsupported.
stat summarises an archive: file counts, total size, compression ratio
and a breakdown per extension. search finds entries by path. validate
walks every entry and reports the ones that fail to parse. inspect
prints what is known about a single entry, including the resource
version and the system and graphics page sizes for resources.

That is the set the GUI offers for looking at an archive without opening
one of its files.
The project had the analyzer packages referenced but AnalysisLevel at
'latest', which leaves most of the rules off, and no .editorconfig of
its own, so it inherited whatever the solution root happened to say.

This project now carries its own .editorconfig: latest-recommended,
EnforceCodeStyleInBuild so the style rules fail the build rather than
decorating the editor, and the naming, ordering and expression-style
rules raised from suggestion to warning. CA1308 is the one that goes the
other way, since lowercasing entry names is exactly what the RPF format
wants.

Clearing what that flagged is the rest of the diff: records sealed,
accessibility modifiers spelled out, collection and object initializers,
null propagation, compound assignment, and documentation comments
generated so CS1591 has something to check.
The JSON contract says success is false when errorMessages is non-empty,
and the exit code is 1 in the same case. Neither held.

- diff set Success to a literal true, so a failed comparison still
  reported success.
- export, extract, gen9, pack and validate derived Success and the exit
  code from their own error counters, which did not include the scan
  errors collected while opening the archive. An archive that failed to
  scan reported success and exited 0.
- inspect and search returned 0 unconditionally.

The counts were as inconsistent. export reported GrandTotalFileCount as
totalFiles, which counts nested archives as files; a dry run counted
nothing as exported because the status is "dry_run" and only "exported"
was matched; export marked a format it does not handle as "skipped",
which is also what --no-overwrite reports, so the two could not be told
apart, and it is "unsupported" now.

The rest are smaller: pack never set RpfManager.IsGen9, so --gen9 did
nothing there; diff's JSON was missing the formatted sizes its text
output printed; the progress bar could compute a fill wider than the
bar; inspect built its per-type details with untyped casts; the
exporters created the output directory before knowing whether the file
would produce anything, so an archive of unsupported files left a tree
of empty directories; pack accepted --threads without using it.
Debugging the CLI from VS Code needed the launch configuration written
by hand every time, since the solution's own configurations all point at
the WinForms projects.
Nothing was tested. CodeWalker.Cli.Tests builds the same sources with
TESTING defined and xunit v3 on top, so the internals are reachable
without making them public.

Coverage starts with the pieces that are pure functions and easy to get
wrong: the glob translation, the size formatting in both unit systems,
the progress bar's rendering and throttling, the option parsing, and the
net48 string polyfills, which are the only place where the three target
frameworks can disagree about behaviour. The polyfill fuzz tests compare
each polyfill against the framework method it stands in for.

The polyfills were compiled out on anything newer than netstandard2.1,
which meant net8.0 and net10.0 could not exercise them at all; the guard
excludes netcoreapp2.1 and up and admits them under TESTING.

The Json records are excluded from coverage. They are property bags with
no branches.
No behaviour change.

this. on instance members, predefined type names, parentheses in binary
expressions and discards for unused results were all at silent or
suggestion, so nothing enforced them and the tree drifted. They are
suggestions or warnings now, IDE0130 included, and the reformat is what
that produced.
The polyfills were written against APIs the framework they stand in for
does not have. string.IndexOf(char, StringComparison) arrived in .NET
Core 2.1; on net48 that call binds to IndexOf(char, int) and does not
compile. CompareInfo.IndexOf with spans and an out match length is newer
still.

Contains(char) converts to a string before searching. ReplaceCore takes
strings and recovers the match length through a helper, since the
overload that reports it is not available here. The char overloads of
StartsWith and EndsWith that took a StringComparison are gone: a
comparison mode says nothing about a single character matched at one
position, and nothing called them.
Hand-written accumulate loops over rpf.AllEntries appeared in most
handlers, each rebuilding the same filter and the same running totals.
They are LINQ now, which is shorter and states what is being counted.

Ctrl+C was not handled at all, so interrupting a long walk over a large
archive killed the process wherever it happened to be, including part
way through writing an output file. Program installs a
CancellationTokenSource on CancelKeyPress and every command takes the
token; the collection loops and the Parallel.For options observe it.

inspect's per-type details were built as an untyped object, so the JSON
shape depended on which branch ran and nothing checked the field names.
They are records now.

The audio exporter treats a stream whose hash is 0 as metadata with no
playable data and skips it. That was unexplained; there is a comment
saying why. Copying a file over an existing one deletes and re-copies,
which is two operations and a window where neither exists; File.Copy
overwrites in one.
diff, export, extract, gen9, hash, inspect, list, pack, search, stat,
tree and validate each get a test file. The archives are built in the
test itself, so nothing depends on a GTA V installation being present.
Building net48 outside Windows needs the reference assemblies as a
package, since there is no .NET Framework install to reference. Without
it the whole project could only be built on Windows, and the net48
target is exactly the one most likely to break.

The tests still only run under Mono on Linux, but they do run.
Every handler test called Execute with CancellationToken.None, so a test
that hung had nothing to stop it and the runner's own timeout was the
only backstop. They pass TestContext.Current.CancellationToken.

The polyfill fuzz tests gain the edge cases the framework methods
special-case: empty needles, empty haystacks, and a needle longer than
what it is searched in.
Twelve handlers, their Json records and their tests were all in the
project root, so the file list was two dozen entries deep before
reaching anything structural. Handlers/ and Tests/Handlers/ mirror each
other.
Both projects in this directory declared the same WarningsAsErrors
property. Directory.Build.props already holds everything else they
share.
ProgressBar wrote the bar, the stats and the padding as separate calls,
so a redraw could interleave with another thread's output and a long
file name could wrap the line. The line is built as one string now,
clamped to the terminal width, and written once. Throttling moved from
DateTime.Now to a Stopwatch, which does not jump when the system clock
does. Update is private, Increment is the only way in and it stops at
the total, and Dispose is idempotent and takes the same lock as the
render.

CollectFiles and the non-RPF count expressed the same predicate two
different ways; they share one now.

The export aggregation counted a file as both errored and skipped, and
took its exit code from the error count rather than from the messages it
was about to print, so a scan error could be reported without changing
the exit code.

The ThrowIfCancellationRequested at the top of each Parallel.For body is
redundant: ParallelOptions.CancellationToken is already checked before
every iteration.

Tests cover ProgressBar, SizeFormat, Filter and both services.
Both handlers computed and printed in one pass, so nothing could be
tested without capturing console output and parsing it back. Each is a
Collect step returning the JSON result record plus one Print step per
output format, and the tests call those directly.

hash's encoding switch became ParseEncoding, which throws on an unknown
name instead of returning an error result from the middle of Execute.
The 'utf8' spelling that --encoding never accepted is gone; the default
is the 'utf-8' the option documents.

stat's JSON gained the *Formatted counterparts the other commands
already emitted alongside their raw sizes: compressedSize,
uncompressedSize, and the per-extension avg, min and max.
CollectFiles dropped every entry whose name ends in .rpf regardless of
--recursive. That is right while recursing, since the archive's contents
are walked in its place, but without it the nested archives simply
vanished from the listing and there was no way to see that they exist.
hash parsed --encoding before entering the try block, so an unrecognised
name escaped Execute as an unhandled ArgumentException instead of the
error result and exit code 1 that every other failure produces.

stat declared its scanErrors list inside the try block, so the catch
could not reach it: an archive that logged scan errors and then threw
reported the exception alone and dropped everything found on the way.
An entry stores 0 in FileSize when it is not compressed, and its real
size comes from FileUncompressedSize or from the system and graphics
page sizes. stat's total already went through GetFileSize, which handles
that, but compressedSize read FileSize directly, so every uncompressed
entry contributed nothing to it and the compression ratio came out too
low.

The entry type test is a switch now, and an entry that is neither a
resource nor a binary throws instead of being dropped from both counts.

The extension table gains its outer border, so the columns close against
the dividers that were already between them.

Trailing commas in the initializers of these two files are gone; the
rest of the project does not use them.
CollectStats, PrintStats and PrintJsonStats had no tests of their own.
These cover the extension grouping, the compression ratio, the size
formatting in both unit systems, and the error paths.
Same split as hash and stat: a Collect step that returns the JSON result
record, and a Print step per output format.

Every handler declared its scanErrors list inside the try block, so the
catch could not reach it and an archive that logged scan errors before
throwing reported only the exception. The list is declared before the
try in extract, inspect, list, stat and validate, and the catch passes
it on.

list no longer takes --threads. It never spawned any.

tree's JSON nodes carry the resource version, which the human-readable
output was already printing.
The matcher tried to guess whether a pattern was a glob and silently
switched behaviour on the answer, so '*' in a file name and '*' as a
wildcard could not be told apart. Matching is a plain case-insensitive
substring test now, and the JSON says so.

--dir searches every archive below a directory, which is what looking
for a file across an installation actually needs. search marks --rpf
optional and validates that exactly one of the two is given. The result
carries rpfFiles, the archives that were searched, and each match names
the archive it came from.

nameHash and shortNameHash are gone from the match records. They were
the Jenkins hashes of the entry name, which the hash command already
prints, and nothing here consumed them.
diff compared two archives through one CommonOptions, so both sides
shared a single --exe and a single --gen9. Comparing a legacy
installation against an Enhanced one, which is the reason to run it, was
not expressible.

The options record is flat and names each side: --left-exe, --right-exe,
--left-gen9, --right-gen9. --progress is accepted here too, and the
handler is split into CollectDiff and the two Print steps like the
others.
RpfCommandOptions, CommonCommandOptions and ExportCommandOptions each
wrapped a group of options in a type that handlers had to compose, so
the options records nested a level deep and every read went through
another hop: options.Rpf.Recursive rather than options.Recursive.

CliOptions replaces all three with one factory method per option. A
handler lists the options it accepts, and its options record is flat.

RpfService and ExportService move under Helpers/ as RpfHelper and
ExportPipeline, and the BaseResult and ReportError pair they had
accumulated moves out to its own Output.cs.
--gen9 only selected the encryption keys. Resource readers consult the
process-wide RpfManager.IsGen9 to pick the memory layout, and nothing
set it, so every resource file parsed as legacy against an Enhanced
install: export textures threw on every .ytd, validate reported nothing
valid, and inspect printed garbage dimensions.

Setting it in LoadKeys covers every command, since they all reach it
through ValidateAndLoadKeys or ValidateExeAndLoadKeys. The pack and gen9
handlers keep their own save/restore around the flag: tests run several
handlers in one process and must not leak it between them.
Keys are process-wide, so loading the left installation's keys and then
the right's before opening either archive left both sides being read
with the right side's keys. --left-exe and --right-exe could not both
be honoured, and neither could --left-gen9 and --right-gen9.

Each side is now scanned while its own keys are loaded. Metadata is
collected first, which identifies the entries that share a path, size
and type and therefore still need a content comparison; only those are
extracted and hashed, so the amount of data read is unchanged.

Entries are also keyed relative to the archive root instead of by their
full path. The archive's own file name is the first path component, so
comparing two archives with different file names previously reported
every entry as both added and removed.
Both commands reported every file in the archive as totalFiles and
folded the filter's exclusions into the skipped count, so extracting a
single file out of common.rpf read as '1 extracted, 664 skipped' out of
665. list and stat already counted what the filter selected.

totalFiles is now the selected set and skipped counts only real skips:
an existing output under --no-overwrite, or a format the exporter does
not handle. RpfHelper.CountNonRpfFiles existed only to produce the old
archive-wide number and is gone.
COMMANDS.md had drifted well away from the code: it described diff with
a single --exe, listed --threads on commands that never had it, omitted
search's --dir, and its sample output and hashes were invented. Rather
than resynchronise a file that will drift again, the parts --help could
not already express now live in the help output itself.

Every command's help gains its --json field list and the exit codes, and
the root gains the stdout/stderr split. System.CommandLine 2.0.2 keeps
HelpBuilder and HelpContext internal, so CustomizeLayout is unreachable
and HelpLayout replaces the help option's action instead.

Two things the old document described correctly and the code did not:

- hash reported its encoding as 'UTF8', which --encoding rejects. It now
  reports 'utf-8' and 'ascii', the spellings that parse.
- search's patternType was pinned to 'substring' after glob detection was
  removed, so the JSON field and the '(substring)' suffix said nothing.

--progress was defined four separate times with three different
descriptions; it comes from CliOptions now, as --dry-run and --output
already did. --output's default is expressed relatively so help shows
'.' instead of whichever directory help happened to be run from.
No behaviour change.

HashHandler and StatHandler carried full param/returns/exception blocks
that restated their signatures, while the other fourteen files used a
one-line summary or nothing. They now match. The same goes for the param
tags on Filter and SizeFormat; ProgressBar and ExportPipeline keep
theirs, where the text describes contract details the names do not.

Comments that repeated the statement below them are gone, along with the
decorative rules separating test sections. The box-drawing characters in
TreeHandlerTests stay: they quote the glyphs the command actually emits.

The cancellation rethrow is written the same way in every handler now,
without the comment that was copied alongside it five times.

csharp_style_var_* was already set to prefer explicit types but only at
suggestion level, so nothing enforced it. Raised to warning; no code
changes needed, the tree already complies.
Directory.Build.props redirects build output to bin/$(MSBuildProjectName)
so the two projects sharing this directory do not collide, but the VS Code
launch configurations still pointed at bin/Debug, so none of them could
start the CLI.

IndexRange 1.1.0 brings Microsoft.Bcl.Memory 9.0.0 in on net48, which
carries GHSA-73j8-2gch-69rq; pinned to the patched 10.0.4. The build is
warning-free again.
Sizes, the compression ratio and vector components were formatted with
the ambient culture, so a machine with a comma decimal separator emitted
'18,28 MiB' where another emitted '18.28 MiB'. Those strings are not
only printed: they are the *Formatted fields in --json, which is
documented for scripting, so the same archive described itself
differently depending on where the tool ran.
RPF entry paths are separated with backslashes on every platform, but
the export pipeline handed the raw path to Path.GetDirectoryName before
translating them. On a platform whose separator is not a backslash the
whole path reads as a bare file name, so the directory came back empty
and every exported file landed in the output root.

Entries sharing a name then overwrote each other silently: exporting the
localization files out of x64b.rpf reported 6804 written and left 567 on
disk, one per name rather than one per language.

Extract already translated the separators before splitting the path;
export now does the same, so both mirror the archive.
The project sets end_of_line = crlf and EnforceCodeStyleInBuild makes
dotnet format check the file as it sits on disk, but the repository root
normalizes with '* text=auto', so a non-Windows checkout writes LF and
every file reports ENDOFLINE. The two could never agree off Windows.

A .gitattributes scoped to this project forces crlf on checkout
everywhere. Blobs are still stored normalized, and the root file is left
alone so nothing conflicts with upstream.
Nothing ran the build or the 534 tests automatically; the repository had
a .github directory with only FUNDING.yml in it.

Windows covers all three target frameworks, including net48 natively.
Linux is not redundant coverage: the archive-name path join in pack, and
the export pipeline collapsing its output into one directory, both only
reproduced off Windows, so a Linux leg is what would have caught them.

Scoped by path to CodeWalker.Cli and CodeWalker.Core, since the rest of
the solution is Windows-only WinForms and shader projects that do not
build in a plain runner.

The build passes -warnaserror. The project is warning-free today, so
this is a regression gate; note it also promotes NuGet advisory
warnings, which can appear on a branch that has not changed.
ExtractFileResource skips the 16 byte header, and when DecompressBytes
returns null it hands back the still-compressed bytes and subtracts
those 16 from entry.FileSize to match. The entry is shared, so that is
permanent: each extraction of the same file reads 16 bytes less than the
one before.

    pass 0: entry.FileSize=73437 extracted=73421
    pass 1: entry.FileSize=73405 extracted=73405
    pass 2: entry.FileSize=73389 extracted=73389

It is also a data race: the CLI extracts across sixteen threads, and
nothing guards the field.

The data is short either way, and the caller has to deal with that.
ResourceDataReader built its two MemoryStreams straight from the page
flags, so a buffer smaller than they describe surfaced as an
ArgumentException about offset and length with nothing to say which file
it was. It checks first, and names the file and both sizes.

Found on one entry in x64a.rpf of a GTA V Enhanced install whose deflate
stream decodes at no offset. The other 24002 resources checked across
five archives decompress to exactly their flagged size, so this is one
bad file rather than a format CodeWalker cannot read.

Not covered by a test: reaching this path needs a real archive holding a
deliberately corrupt resource, and there is no test project over
CodeWalker.Core to build one in.
The xunit runner writes its failures to a file under TestResults and
prints only the counts, so a failed run said "Failed: 1, Passed: 533"
and named neither the test nor the assertion. The log is dumped on
failure.

The loop also ran under -e, so the first framework to fail skipped the
rest. It runs all of them and fails afterwards, which matters here
because net48 is first and the two modern targets never got a chance to
report.
OutputDirectory_ComputedFromBackslashPath asserted that the directory an
export lands in contains no backslash. That only holds where the
platform separator is not a backslash, so it failed on Windows once the
translation it was named for actually worked:

    Assert.DoesNotContain() Failure: Sub-string found
    String: "/out\x64\levels\gta5\vehicles.rpf"
    Found:  "\"

It never tested what it claimed either. It was written while the
separators were not translated at all, and back then the directory came
back as the bare output root, which contains no backslash and starts
with /out, so both of its assertions passed against the bug.

BackslashEntryPath_MirrorsArchiveStructureInOutputDir covers the same
path and compares against Path.Combine of the expected segments, which
holds on either separator.
The existing jobs build CodeWalker.Cli and nothing else, so a change to
CodeWalker.Core that breaks the WinForms projects consuming it would
pass. This branch already carries such a change, and none of it had been
compiled anywhere.

msbuild rather than dotnet build, because the solution includes a C++
project for the shaders. Debug rather than Release: CodeWalker.csproj
sets PlatformTarget to x64 only under Release|AnyCPU, and the SDK then
infers a win-x64 RuntimeIdentifier that restore has not produced, which
fails as NETSDK1047. One invocation with -restore keeps the restore and
the build on the same evaluation.

No -warnaserror here. This is a compile gate over code the CLI work does
not own; the warning gate stays on the project this branch is for.

The path filters gain the projects the job builds, so a change confined
to the GUI still runs it.
Nothing was kept from a run, so trying a build meant compiling it.

The CLI publishes three ways: self-contained single-file for win-x64 and
linux-x64 from net10.0, which run with nothing installed, and a net48
build for Windows machines that already have the framework. No trimming,
since the JSON output goes through reflection.

The solution job uploads the Release output of all seven apps, one folder
per project because each carries its own copy of the shared assemblies.

It built Debug until now because Release failed with NETSDK1047:
CodeWalker.csproj sets PlatformTarget to x64 under Release|AnyCPU and the
SDK then infers a win-x64 RuntimeIdentifier that restore has not
produced. Clearing RuntimeIdentifier keeps the AnyCPU assets, so the
artifacts are Release builds and no project file changes.
The requirements list was the app's, with nothing saying so, and it had
drifted: .NET Framework 4.5 where the projects target 4.8, behind a link
to the 4.7.1 download, and no mention of Enhanced although the tree has
Gen9 support. Each list now names the program it is for, and the command
line program's drops the graphics and memory figures that do not apply.

CodeWalker.Cli was not mentioned anywhere. It gets a section next to the
menu and explorer modes, since all three are ways of using this without
the world view.
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