Skip to content

Phase 1: new core API - #52

Merged
Frotty merged 10 commits into
masterfrom
phase1-core
Aug 20, 2026
Merged

Phase 1: new core API#52
Frotty merged 10 commits into
masterfrom
phase1-core

Conversation

@Frotty

@Frotty Frotty commented Aug 20, 2026

Copy link
Copy Markdown
Member

Phase 1 of AUDIT.md: the new core API, read and write, in a new org.inwc3.jmpq package. The existing JMpqEditor API is untouched and its tests are green, so nothing downstream changes yet.

Decisions the audit delegated

  • org.inwc3 as the package root, replacing systems.crigges. A reverse-domain form Maven Central would accept, unlike a bare inwc3 or com.github.inwc3. The artifact group and publication groupId moved too.
  • systems.crigges.jmpq3 is retained as the compatibility layer. JMpqEditor stays importable at its current fully-qualified name so WurstScript compiles unchanged; it becomes a deprecated adapter over this core in a follow-up.
  • Crypto, compression, hash table and list file code is reused, not reimplemented. All of it was hardened in Phase 0; a parallel copy would only be a chance to diverge.

The new core

Class Role
MpqSource bounds-checked, little-endian read model over a MemorySegment (P1-3)
MpqHeader immutable header model, all four versions (P1-2)
MpqFileEntry name + locale + flags + sizes, replacing raw Block exposure (P1-2)
MpqArchive open, enumerate, look up, decode — read only
MpqArchiveWriter build in memory, write on an explicit save (P1-2)
MpqOpenOptions / MpqWriteOptions locale (P1-5), explicit format version (P1-6), extension hooks (P1-8)

FFM read layer. File-backed sources map into an Arena-scoped MemorySegment, so the mapping is released deterministically. That is what forced the old design's hand: mapped byte buffers unmap only when the collector gets round to them, so on Windows the archive stayed locked after close() and the rebuild had to stage through a temp file. Three tests delete the archive immediately after closing, which fails outright on Windows if a mapping is live. Offsets are long, and out-of-range reads raise JMpqException rather than IndexOutOfBoundsException.

Saving is explicit. The old JMpqEditor rebuilt the archive as a side effect of close(), so a missed flag or a throw on the way out could rewrite a file that was only meant to be read. Reading is MpqArchive, writing is MpqArchiveWriter, and the write happens where the caller asks for it.

Format version is chosen, not inherited (P1-6). MpqWriteOptions accepts 0 or 1 and rejects the rest at construction, rather than emitting a 208-byte header carrying 32 bytes of meaning as the old writer did for a version 2 source.

Verbatim copies are conditional on sector size. A file keeps its stored bytes only when the target keeps the source's sector size, because a sector offset table is expressed in that sector size. This is the corruption the golden harness found in the old recompression path, now a rule the writer applies rather than a case it can forget.

Header repair, not rejection. Following StormLib's ConvertMpqHeaderToFormat4: a garbage header size becomes the version's real size and the archive is flagged malformed. listfileTooLong.w3x and spazzledMap.w3x parse, which is groundwork for P2-5a (issue #46).

Verification

Numbers from the test report, not asserted in prose:

Check Result
New core read vs. independent reference 171 files — every one it can decode
New core read vs. existing implementation 199 files — adds the PKWARE/Huffman/ADPCM files the reference cannot reach
Writer round-trip, copy path 179 files preserved
Writer round-trip, changed sector size 179 files preserved — the case the old writer corrupted
Writer output read by the independent reference 179 files, 0 skipped, 0 failures

That last row is the strongest of them: zero skipped means every sector the writer emitted was decodable by an implementation that shares no code with this one. CI runs it on both platforms.

Also covered: streaming equals whole-file read; reproducible output; explicit format selection; forced table capacity; prefix preservation; atomic save; rejection of internal names the writer generates; and put copying its input.

Remaining in Phase 1 (follow-up PR)

JMpqEditor reduced to a deprecated adapter over this core, which is also when the legacy implementation moves out of systems.crigges.jmpq3 and leaves only shims; behaviour contracts documented and tested (P1-7); retiring the w3p branch against the new extension hooks (P1-8).

One known limitation is recorded as P4-3a in AUDIT.md: a block may declare any normalSize and the codecs allocate their expected output up front. Fixing it means changing how the codecs produce output, which wants the Jazzer fuzz harness to validate, so it belongs with the Phase 4 coverage work.

Frotty added 2 commits August 20, 2026 19:36
The --names path was fixed for this; the archive-internal parser still
used line.strip() as its truth test, so a name made entirely of
whitespace was dropped. That left the oracle inconsistent with the
library, which keeps such names, and no fixture exercises one.
First slice of the new core in systems.crigges.jmpq. Nothing uses it
yet, so the existing API and tests are untouched.

MpqSource is the single bounds-checked, little-endian read model (P1-3).
A file-backed source maps into an Arena-scoped MemorySegment, which
fixes the problem that forced the old design's hand: mapped byte buffers
unmap only when the collector gets round to them, so on Windows the
archive stayed locked after close() and the rebuild had to stage through
a temp file. A test deletes the archive immediately after closing the
source, which fails outright on Windows if a mapping is still live.
Offsets are long, so a v2+ archive past 2 GiB needs no chunking, and
out-of-range reads report JMpqException rather than
IndexOutOfBoundsException because they mean a damaged archive.

MpqHeader parses all four versions into an immutable record with the
field layout documented against StormLib's TMPQHeader, verified from
source rather than memory. It repairs instead of rejecting, following
ConvertMpqHeaderToFormat4: a wrong declared header size is replaced by
the version's real size and the archive is flagged malformed, which is
what makes the protected maps of issue #46 readable and is groundwork
for P2-5a. The same applies to a high byte in wSectorSize and to a
block table larger than the file, which is clamped as StormLib clamps
it.

Every value that could drive an allocation is bounded at parse time:
hash entries against StormLib's HASH_TABLE_SIZE_MAX, block entries
against what the file holds, the sector shift against what keeps
512 << shift positive, and the declared archive size against the file,
for every archive rather than only in legacy mode.

Package is systems.crigges.jmpq, the audit's first suggestion, so the
package carries no version number; the deprecated adapters will stay in
systems.crigges.jmpq3. JMpqException is reused from there deliberately
rather than duplicated, since it is what consumers already catch.
@coveralls

coveralls commented Aug 20, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 77.234% (+0.03%) from 77.204% — phase1-core into master

MpqArchive is now a working read-only archive, verified against the
independent reference rather than against its predecessor.

- MpqFileEntry replaces handing callers a raw block table row. A Block is
  a mutable row that does not know its own name; this is an immutable
  value object carrying name, locale, flags and sizes, so callers stop
  correlating the two tables themselves. Sector-offset-table presence is
  derived from the flags rather than from a sector count of 1.
- MpqFileReader decodes a file straight out of the mapped segment, a
  sector at a time, so decoding never materialises the compressed form on
  the heap as the old path did.
- MpqArchive opens from a Path, byte array or channel, enumerates via the
  list file, looks up by name and locale, and decodes whole or streaming.
  It never writes: producing a modified archive will be the writer's job
  with an explicit save, so that a read can no longer rewrite the file as
  a side effect of close().
- MpqOpenOptions carries forceV0 and the preferred locale, which starts
  P1-5. MpqOpenOptions.warcraft3() is the mode these fixtures need.

The crypto, compression, hash table and list file code is reused rather
than reimplemented: it was hardened in Phase 0 and a parallel copy would
only be a chance to diverge.

Verification, from the test report rather than by assertion alone:
171 files match the independent reference, every one it can decode, and
199 files match the existing implementation, which additionally covers
the PKWARE, Huffman and ADPCM files the reference deliberately does not
implement. A shared misreading of those codecs would show up as a
divergence between the two.

Self-review before pushing caught one thing the tests could not: the
block table's byte count was computed as int * int, so a 2 GiB archive
describing enough entries would overflow to a negative length and fail
complaining about the wrong thing.
@Frotty
Frotty marked this pull request as ready for review August 20, 2026 18:11

@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: 1e6a605cd5

ℹ️ 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".

Comment thread src/main/java/org/inwc3/jmpq/MpqFileReader.java Outdated
Comment thread src/main/java/org/inwc3/jmpq/MpqArchive.java Outdated
Comment thread src/main/java/org/inwc3/jmpq/MpqFileReader.java
Frotty added 3 commits August 20, 2026 20:46
The crigges namespace names a member who is no longer active; the code
should carry the collective's name. Package root is org.inwc3, so it is a
reverse-domain form that Maven Central would accept, unlike a bare
inwc3 or a com.github.* group.

This commit moves only the new core, which nothing depends on yet, so it
is a rename with no behavioural surface. Also fixes the group and
publication groupId, which Phase 0 had reconciled onto systems.crigges.

The legacy implementation still sits in systems.crigges.jmpq3 and moves
in the commit that introduces the compatibility adapter, since that is
where the split between shim and implementation is actually decided.
The end state, per the maintainer's call: everything real under
org.inwc3, and systems.crigges.jmpq3 retained purely as deprecated shims
so JMpqEditor stays importable and WurstScript compiles unchanged.
MpqArchiveWriter builds an archive in memory and writes it when told to.

Saving is explicit (P1-2). The pre-2.0 JMpqEditor rebuilt the archive as
a side effect of close(), so a missed flag or a throw on the way out
could rewrite a file that was only meant to be read. Reading is
MpqArchive, writing is MpqArchiveWriter, and the write happens where the
caller asks for it.

Format version is chosen, not inherited (P1-6). MpqWriteOptions accepts
0 or 1 and rejects the rest at construction, rather than emitting a
208-byte header carrying 32 bytes of meaning as the old writer did for a
version 2 source.

Verbatim copies are conditional on sector size. A file keeps its stored
bytes only when the target archive keeps the source's sector size,
because a sector offset table is expressed in that sector size. This is
the bug the golden harness found in the old recompression path, encoded
as a rule the writer applies rather than a case it can forget.

MpqWriteOptions carries the P1-8 extension points: an explicit hash
table capacity and extra unused block slots, enough for protection
tooling to emit a maximised table without that being a concern of this
library. Also listfile and prefix policy, since a Warcraft III map stops
loading if its 512-byte prefix is dropped.

save(Path) stages to a sibling file and moves it into place, so an
interrupted save cannot leave a half-written archive where a working one
used to be. It cannot target an archive that is still open: a mapped file
cannot be replaced on Windows. The first version of this claimed
otherwise and the test caught it, so the javadoc now spells out the
build-then-write pattern instead.

Verification: the writer preserves 179 files across all fixtures on the
copy path, and the same 179 when re-encoding into a different sector
size, which is the case the old writer corrupted. Plus reproducible
output, explicit format selection, table capacity control, prefix
handling, atomic save, and rejection of the internal names it generates.

Self-review before pushing: block table sizing was int * int, and unlike
the hash table its capacity is caller-influenced through
extraBlockEntries, so it could overflow.
The golden harness already confirmed that something other than this
library can read what the old writer produced. The new writer now gets
the same treatment: the test exports its output plus the digests each
archive should hold, and CI has tools/mpqref.py verify them.

Result is 179 files across 10 archives with 0 skipped and 0 failures.
Zero skipped is the part that matters: re-encoding leaves every sector
as deflate or stored, which is exactly what the reference implements, so
nothing is waved through as unverifiable.
@Frotty

Frotty commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 624579c7b4

ℹ️ 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".

Comment thread src/main/java/org/inwc3/jmpq/MpqArchiveWriter.java Outdated
Frotty added 3 commits August 20, 2026 21:04
Review finding, and a real corruption bug rather than a regression: it
is present in master too.

An encrypted file with no compression flag still occupies fixed-size
sectors, and each is encrypted with its own key + index. The missing
compression flag removes the sector offset table, not the sectors.
StormLib decrypts inside the per-sector loop in ReadMpqSectors whatever
the compression flags say; those only decide whether an offset table is
consulted, and the whole-block decrypt at line 290 is the SINGLE_UNIT
path, which is separate.

Both implementations decrypted such a file as one block, so sector 0
decoded and every sector after it was returned as garbage. Fixed in the
new core and in the compat layer.

No shipped fixture has a file of this shape, which is how it survived a
golden-file suite that checks 199 files against two other
implementations, so the archive is built by hand in the test. Verified
the test actually catches it by reintroducing the bug and watching it
fail.

The reference tool had the identical bug and would have confirmed the
wrong answer, which is the second time a shared misconception has slipped
past it. Fixed there too, and CI now has it verify the hand-built
archives, so agreement between the two is real evidence.

Also from the same review: an uncompressed SINGLE_UNIT entry now checks
that its stored size equals its declared size, the invariant the stored
path already enforced. Without it a damaged block table silently yielded
the wrong number of bytes from an API that promises an exception.
MpqArchive.entries() resolves one locale per name, so an archive with
several localised variants of a path misreports the others as locale 0
with no name, which also makes encrypted variants unreadable through the
returned entry.

Real, but unreachable with any current fixture, and fixing it needs the
hash table to expose all its mappings rather than one lookup per name.
Recorded against P1-5 so it is fixed where a multi-locale fixture can be
built to prove it, rather than done blind on the strength of a P2.
Review finding, P1: a rebuild silently dropped every localised variant
of a path but one, and relabelled the survivor as neutral. MPQ
identifies a file by path and locale, but the writer keyed pending files
on path alone and registered them all with locale 0. The same review
raised a related P2 on the read side, where entries() named blocks by
looking each path up once, so the other variants came back with no name
and locale 0 -- misstating the locale and leaving an encrypted variant
unreadable through the entry, because the key derives from the name.

Both needed the same missing capability, so this does the P1-5 locale
work rather than deferring twice. HashTable can now report all its live
mappings, which is the only way to discover the variants of a path: a
lookup resolves one by the format's preference order and cannot report
the rest.

On that footing:
- MpqArchive.entries() correlates every mapping, so each block carries
  its real locale and every named variant keeps its name.
- MpqArchive.localesOf(name) reports the locales a path is stored under.
- The writer keys pending files on path and locale, carries every variant
  across in from(), and registers each under its own locale. put and
  remove gained locale-aware overloads; the neutral-locale forms are
  unchanged, so existing calls behave as before.
- The rebuilt list file still names each path once, whatever its locale
  count, because a list file names paths rather than variants.

Neither defect was reachable with any shipped fixture: every one is
entirely neutral, which is how both survived a suite checking hundreds
of files against two other implementations. The new test builds a
three-locale archive and covers read, enumerate, rebuild and
locale-aware removal.

Master has the same write-side behaviour, so this is a defect rather than
a regression.
@Frotty

Frotty commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

All four findings addressed in 6e4077b and 0cfb609. Two were defects present in master rather than regressions in this branch, and neither was reachable with any shipped fixture — every one is entirely neutral-locale and none holds an encrypted uncompressed multi-sector file — which is how they survived a suite checking hundreds of files against two other implementations.

  • Stored files are decrypted per sector (P1). Verified against StormLib first: ReadMpqSectors decrypts inside the per-sector loop with key + index whatever the compression flags say; those only decide whether an offset table is consulted, and the whole-block decrypt is the separate SINGLE_UNIT path. Both cores decrypted such a file as one block, so sector 0 decoded and the rest came back as garbage. Fixed in the new core and the compat layer. The reference tool had the identical bug and would have confirmed the wrong answer, so it is fixed too and CI now has it verify the hand-built archives. I also reintroduced the bug to confirm the new test actually fails on it.
  • Locale is carried through enumeration and the writer (P1 + P2). These needed the same missing capability, so this does the P1-5 locale work rather than deferring twice: HashTable can now report all live mappings, entries() correlates every one of them, localesOf(name) reports a path's locales, and the writer keys on path and locale so a rebuild carries every variant across under its own locale. Neutral-locale calls are unchanged.
  • Uncompressed SINGLE_UNIT size invariant now matches the stored path, so a damaged block table raises rather than silently returning the wrong length.

New tests build the two fixture shapes that were missing: a three-locale archive, and an encrypted uncompressed multi-sector file.

@Frotty

Frotty commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

@codex review

@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: 0cfb60995f

ℹ️ 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".

Comment thread src/main/java/org/inwc3/jmpq/MpqFileReader.java Outdated
Comment thread src/main/java/org/inwc3/jmpq/MpqArchive.java
Comment thread src/main/java/org/inwc3/jmpq/MpqArchive.java
Review findings on the previous commit.

The P1 is the same defect I had just fixed, in the sibling I did not
check: the verbatim-copy path decrypted a stored encrypted file as one
block, so it wrote a correct first sector and corrupt ones after it. It
is worse there than on the read path, because the writer then clears the
encryption flags, making the corruption permanent in the rebuilt archive
rather than a bad read of intact data. Present in both the new core and
the compat layer's copy path.

This is the fifth time in this branch that fixing the instance I was
shown left a sibling behind, so this time I enumerated every decryption
site in both cores first. That turned up exactly the two remaining ones,
both distinguishing SINGLE_UNIT from stored incorrectly.

Archives carrying a hi-block table are now refused instead of misread. A
hi-block table holds the upper 16 bits of each file position for archives
whose data passes 4 GiB; the header was parsed but never applied, so a
read would seek to the wrong place. Supporting it belongs with the v2-v4
read work, and refusing is honest in the meantime.

Tests cover the rebuild path for both cores. The hand-built fixture now
carries a list file, because without one there is nothing for a writer to
carry over and the copy path is never reached; the first version of the
test passed for that reason rather than because the code was right.
@Frotty

Frotty commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 153db4cd07

ℹ️ 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".

@Frotty
Frotty merged commit 6b34171 into master Aug 20, 2026
3 checks passed
@Frotty
Frotty deleted the phase1-core branch August 20, 2026 20:59
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.

2 participants