Commit Graph
14 Commits
Author SHA1 Message Date
Patryk GenschandClaude Opus 5 c2036e1f72 Add a README
Everything so far lived in code comments and CLI help, which works while writing
the thing and not at all when coming back to it after a month. Covers what the
catalogue is, where its knowledge of the Aidem formats comes from (:core through
a composite build, never a fork), how the data is shaped, and how to run it —
pipeline, browsing, transcription, Docker, and moving the database between
machines.

Written against the actual state rather than memory: the command list is what the
binary prints, the table list is what the schema holds, and the submodule URL is
the one in .gitmodules. Volatile figures are left out, with a pointer to
`rex-catalog stats` instead, so the file does not rot on the next ingest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:53:04 +02:00
Patryk GenschandClaude Opus 5 20ce26364c Add relocate, and a CUDA image for transcribing on an NVIDIA box
The database records absolute paths to the disc images, because audio, animation
frames and transcription all read straight out of them. Carrying catalog.sqlite
to another machine therefore leaves a catalogue that displays everything and can
play nothing — and the same already applied between host and container, where the
collection is /Users/... on one side and /media on the other.

`relocate <directory>` re-points every copy, matching on filename and confirming
by size, with --verify adding a full SHA-256 and --dry-run showing the outcome
first. Copies that cannot be matched keep their old path and are reported rather
than quietly rewritten.

Names are compared after Unicode normalisation, which turned out to be the whole
problem in practice: macOS stores "Wojna Trojańska.iso" decomposed (n + combining
acute) while the database held it composed. Two of thirteen copies failed to match
until that was fixed — and the same mismatch is exactly what would happen carrying
a collection between macOS and Windows.

Verified by rewriting the paths in a copy of the database to a Windows-shaped
D:\Kolekcja, relocating, and then reading a voice line and an animation frame out
of the discs through the relocated database.

Dockerfile.cuda builds whisper.cpp with GGML_CUDA for compute capability 8.6
(GeForce RTX 30), on nvidia/cuda for both stages, with the JRE installed on top of
the CUDA runtime. It is a separate file because both base images differ; folding it
into the main Dockerfile would be more conditionals than content. Not verified
beyond `docker build --check`: there is no NVIDIA GPU here, and the images are
amd64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:10:36 +02:00
Patryk GenschandClaude Opus 5 b99f239553 Splice short lines into 30-second windows before transcribing
Whisper always encodes a full 30-second window, whatever the clip length, and the
average line here is 4.4 seconds. Fed one at a time, 19.5 hours of speech costs
what 135 hours of continuous audio would. Lines are now concatenated with a
second of silence between them, filling a window with about five, and the result
is cut back apart using the timestamps from whisper's JSON output. A segment goes
to the line it overlaps most, so a line split across several segments is
reassembled and a segment straying into the silence still lands correctly.

Measured on 48 real lines, same set through both paths:

  speech (39)      similarity 0.92, no text landed under the wrong file
  non-speech (9)   similarity 0.54 — both modes invent music annotations

Splicing turns out to be slightly *more* accurate on speech, because the model
sees context: "Jeden raz czułem" becomes "Niejeden raz czułem", "muszę to pościć"
becomes "puścić", "SOO?" becomes "Co?".

On the whole collection this projects to 5.8 h → 3.5 h, a 1.7× gain rather than
the 5× I claimed earlier from counting encoder windows alone. Encoding a window
and decoding a line cost about the same, 0.65 s each, and decoding is per line no
matter how the audio is packed — so only half the work can be folded away.

Window size, gap and batch size are all settable, and catalog.whisper.concat=false
restores the file-at-a-time path, which is how the two were compared. Output moved
from -otxt to -oj because only the JSON carries the timestamps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:59:55 +02:00
Patryk GenschandClaude Opus 5 a773d791a7 Drop Whisper's invented subtitle credits from transcripts
Measured on 64 real clips with ggml-medium: 54 came back as genuine Polish, 9 as
bracketed non-speech markers that are accurate ([muzyka] on music), and one as
"Napisy stworzone przez społeczność Amara.org" — a credit line Whisper learned
from training data and emits over silence. That is not a record of what is on the
disc, so it does not belong in an archival catalogue. Bracketed markers stay:
they describe the recording truthfully.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:30:14 +02:00
Patryk GenschandClaude Opus 5 930cfb3462 Transcribe in batches instead of one file per whisper run
Every whisper.cpp invocation loads the model from scratch, which for a large
model takes longer than recognising a few seconds of speech. Running it once per
file meant that across seventeen thousand recordings the loading would dominate
the work entirely. Files now go in batches of sixteen — 40 files took 3 runs
instead of 40 in a stub test, with each file still getting its own result.

A batch has to be one language, since -l applies to the whole invocation, so work
is grouped by the language derived from the wavs/<code>/ path. Results come back
as files next to the inputs (-otxt) rather than on stdout, because with several
files in one run stdout cannot be split per file. Cancellation now lands between
batches rather than between files, which at sixteen files is close enough.

Thread count is left at whisper's own default and exposed as
catalog.whisper.threads: raising it buys speed at the cost of heat, and on a
fanless machine that turns into throttling anyway.

Docker: whisper.cpp bumped to v1.9.2 to match what Homebrew installs. The library
naming changed there — versioned sonames like libggml.so.0 — and the copy pattern
had to widen to match, otherwise the binary could not start.

Verified with a stub in place of whisper-cli, on the host and inside the
container: batching holds, each file gets its own text, and the temp directory
works as uid 10001. Nothing was left in the transcript table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:06:08 +02:00
Patryk GenschandClaude Opus 5 cf6fd4e55e Build whisper.cpp into the image and mount the model from outside
Baking the model in was the wrong call: it is the part that weighs gigabytes,
everyone keeps a different one, and it has no business inside an image. The
binary is the opposite — whisper.cpp plus its libraries come to 2 MB. So the
tools go in and the model is mounted under /models, pointed at by WHISPER_MODEL.

Two things had to be worked out to build it. ggml tunes for the building
machine's CPU by default, which on arm64 emits -mcpu=native+nodotprod+noi8mm+nosve
and GCC 12 rejects outright; GGML_NATIVE=OFF fixes that and is what a portable
image wants anyway. And `cmake --install` insists on installing every example,
including binaries we deliberately did not build, so the artefacts are copied
straight out of the build tree.

A missing model is now reported before any work starts, not hit halfway through:
in a container the path is supplied from outside and the file behind it may
simply not be there.

Verified on the running daemon: image builds, and inside the container the full
pipeline reproduces the host run exactly — 13 copies, 880 scripts, 13 884
recordings, 3531 images and 6928 animations (81 515 frames). Graphics decode in
the container too, since the JRE image carries java.desktop. Thumbnails and
on-demand frame rendering answer over the published port, MCP lists 9 tools, the
collection mount rejects writes, the process runs as uid 10001, and data survives
a restart. The transcription chain was exercised with a stub binary in place of
whisper-cli: ffmpeg hands it exactly 16 kHz mono and results reach the database.
Only a real model run remains untried, since no model was downloaded.

Note on size: the image goes from 516 MB to 1.09 GB, and ffmpeg alone accounts
for 410 MB of that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:11:31 +02:00
Patryk GenschandClaude Opus 5 f1b36f0e29 Decode IMG and ANN graphics, and follow the engine's file lookup rules
10 459 images and animations were listed but unviewable. They are now decoded to
PNG without OpenGL, so a script reference like IMGOVERLAY:FILENAME=NAKLADKA.IMG
shows the actual picture.

Decompression comes from :core (CLZW2Compression, CRLECompression) — that is the
hard part and there is no reason to have two copies of it. The header parsing had
to be written here, and not by choice: ImageLoader keeps its parser private, and
AnimoLoader, despite a signature that looks headless-friendly, builds Image
objects whose constructor creates a Texture straight away. Field layout mirrors
those loaders one for one, so catalogue and emulator read the same bytes the same
way. Pixels are RGB565/RGB555 plus a separate alpha byte, composed with ImageIO.

One quirk needed care: ImageLoader maps compression 4 to "none" for IMG files,
but in animation frames the same 4 means real CRLE and AnimoLoader passes it
through. Applying the IMG quirk to ANN turned whole animations into noise.

3531 images and 6928 animations decode (81 515 frames, 29 019 named events);
30 files fail and are recorded with the reason. Previews are thumbnails only —
340 MB of cache instead of decoding everything to disk — and full frames are
rendered from the disc on demand. Animations carry their author: 6513 of them
are signed Piotr Maciejewski.

File lookup now follows the engine instead of guessing. A bare FILENAME means
next to the script; $ is the game root, so $COMMON\X and $WAVS\X resolve there;
WAV files live in wavs/. This matters because a name alone does not identify a
file — Wojna Trojańska ships seventeen different bkg.img, one per scene, and
matching on the name showed the wrong picture for all of them. Resolution is now
98% overall and 97% for WAV with nothing uncertain; the 900 matches that still
fall back to name-only are flagged in the UI rather than passed off as fact.
What stays unresolved is mostly save-state written at runtime.

Two extraction bugs fixed along the way: fields ending in ^N (VARIWST:ONCHANGED^2)
were skipped entirely, hiding 176 real references; and blocking a match at an
underscore made the regex restart one character later, cutting HIST0.ARR out of
+"_HIST0.ARR". Matches must now begin at a token boundary.

Resolution used to run as correlated subqueries over a CTE, re-evaluated per row:
36 s for a script with 745 references. Parameters are now bound directly and
file.basename is a generated, indexed column — 0.19 s.

Docker gains whisper.cpp and ffmpeg (tens of MB); the model is mounted under
/models instead, since it is the part that weighs gigabytes and everyone keeps a
different one. Not verified: the Docker daemon is not running on this machine, so
the image was not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:40:13 +02:00
Patryk GenschandClaude Opus 5 6feb2254b2 Index audio, dialogue tables and script→asset references
Turns the catalogue from a file listing into something you can read: a script
line like SNDQUESTION:FILENAME=KRET_E511.WAV now shows how long the recording
is, who speaks it and at which event, and plays it straight from the disc image.

Four sources feed that:

- Audio headers. RIFF/WAVE and Ogg Vorbis parsed in-process from the image
  stream, no temp files. WAV needs only the first 4 kB, so 3.3 GB of samples
  never reaches the CPU. Not routed through :core — SoundLoader wants a
  FileHandle and a live Gdx.audio, and its contribution is a standard RIFF
  header. 13 884 recordings, 20.2 h.

- wavs/wav.snd. Reksio i Kapitan Nemo packs its whole voice cast into one
  79 MB container that :core does not read, so its speech was invisible here.
  Flat length-prefixed entries holding Ogg Vorbis (oggenc.exe ships on the
  disc). The parser walks the file to its exact last byte: 3274 entries.
  Payloads stay in place — offset and length are enough to serve them, and
  seeking 82 MB into the ISO costs 45 ms.

- dialogi.dta. Pipe-separated CP1250 giving every line a speaker and the event
  that triggers it, so "what is in this file" is answerable without any speech
  recognition. Scene-definition tables share the format, so a row only counts
  as dialogue when column 0 is an identifier rather than a path. 3819 lines,
  98% resolving to real audio.

- Script references. OBJECT:FIELD=VALUE is uniform even inside CODE={...},
  which the decoder folds onto one line, so one pass catches declarations and
  names woven into behaviour code alike. 19 735 references; 97% resolve.
  Names built by concatenation (+"_DEF.DTA") are excluded, but a real leading
  underscore (_WZIECIE_JABLKA.WAV) is kept.

Languages now come from install.ini's [Language] section, with LCIDs translated
through :core's LangCodeConverter rather than a second table here — that is what
settles wavs/slo/ as Slovak. Promote copies them into edition_language, only
ever adding, so curated entries survive.

Transcription is wired to whisper.cpp but never runs on its own: a button with a
progress bar, a stop that keeps what is already computed, and a transcribe
command. Results are generated, not read off the disc, so they live in their own
table with the model and tool named, and the UI labels them as machine guesses.
The pipeline was verified with a stub binary — ffmpeg hands whisper exactly
16 kHz mono, progress and cancellation work, and per-language selection follows
wavs/<code>/. No real transcripts were stored.

Also fixes a pre-existing bug: .cols set display:grid, which beat the browser's
[hidden] rule, so the Skrypty section never actually hid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:50:12 +02:00
Patryk GenschandClaude Opus 5 285b4ed316 Drop JAVA_TOOL_OPTIONS and --system from the image
JAVA_TOOL_OPTIONS made the JVM print "Picked up ..." on every invocation, and
the entrypoint already sets file.encoding through JAVA_OPTS. useradd --system
warned because the uid is above SYS_UID_MAX; the explicit uid is what matters.

Verified end to end on Docker 29.4: image builds, the full pipeline runs
inside the container against a read-only collection mount, the frontend and
MCP answer on the published port, and the database survives a restart in the
named volume. The read-only mount was confirmed to actually reject writes,
and the container runs as uid 10001, not root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:51:39 +02:00
Patryk GenschandClaude Opus 5 02988297ec Add web frontend and Docker packaging
Frontend is a single page served from classpath resources with a JSON API
alongside it. Kept separate from the MCP tools because the consumers differ:
a model reads formatted text, a browser needs structures. Only the database
is shared.

The page has three views - catalog with edition details, copies, detected
facts with evidence and a filterable file list; script search with FTS5
snippets and a viewer; and a format breakdown. No build step, no CDN, no
dependencies; light and dark follow the system.

New `serve` command puts the frontend and MCP on one port, one process and
one database connection, which is also what the container runs. `mcp` alone
still works for a headless setup.

Docker is a two-stage build: JDK plus the Rex-EMoolator submodule to compile,
JRE for runtime. installDist rather than build, so check - and with it
verifyCoreVersion - is skipped and git is not needed in the image. The pinned
:core tag is extracted from gradle.properties at build time and passed in by
the entrypoint, otherwise copy.core_version would record "nieznana" and
derived-artifact invalidation would stop working. Properties go through
JAVA_OPTS because Gradle's launcher treats everything after the script name
as application arguments.

Compose mounts the collection read-only and keeps the database and script
cache in a named volume. The port is bound to loopback.

Not verified: the image itself does not build here, the Docker daemon is not
running on this machine. The launcher, JAVA_OPTS handling and resource
packaging were tested against the installDist output directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:34:51 +02:00
Patryk GenschandClaude Opus 5 393bcde6a8 Add MCP server over Streamable HTTP
Seven read-only tools over the catalog database: list_titles, search_scripts,
get_script, get_edition, list_files, find_file and collection_stats. Writing
stays in the CLI, where the effect of a command is visible.

Transport is Streamable HTTP on the JDK's com.sun.net.httpserver, so Gson is
the only new dependency. No sessions are kept: every tool is stateless, so
Mcp-Session-Id is omitted and GET returns 405 rather than opening an SSE
stream we would never write to. Requests carrying a non-loopback Origin are
rejected, since a page in a browser can POST to localhost.

Tool results are formatted text rather than JSON. The consumer is a model
reading the answer, and prose costs less context than the same data wrapped
in objects.

Required arguments are validated against each tool's inputSchema before
dispatch, so a missing parameter is an explicit tool error instead of a
result computed from a default. find_file groups by the canonical lowercase
path, otherwise an extracted directory and its ISO look like two files.

Database access is serialized on one lock because sqlite-jdbc shares a single
connection; the workload is read-only and single-client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:25:36 +02:00
Patryk GenschandClaude Opus 5 2a051c561b Add script decoding, full-text search and catalog entities
Script pipeline:
- decode: ScriptDecypher from :core into a content-addressed cache keyed by
  blob SHA-1, so a script shared by several images is decoded once. Cache
  invalidation is driven by artifact.tool_version, i.e. the pinned :core tag.
- find/cat: FTS5 index over decoded bodies. tokenchars '_' keeps identifiers
  whole; a query that fails to parse as FTS5 is retried as a quoted phrase.

Catalog entities:
- analyze: MetadataDetector reads dane/application.def for build date, game
  version, engine version and episodes. The APPLICATION object is located by
  type, not by name, since it is GAME, UFO or PIRACI depending on the title.
  CREATIONTIME is recorded separately from release_date because it is the
  project creation date, shared across a whole series.
- promote: builds titles and editions. KnownHashes entries conflate levels
  ("Reksio i UFO (pierwsza wersja)" is title plus edition label), so the
  parenthetical is split off and both UFO releases land under one title.
  Editions are merged on a fingerprint of engine DLL plus application.def
  hash; the DLL alone cannot separate the Herkules/Odyseusz two-in-one disc.
- set/lang: manual metadata a detector cannot infer - provenance, language
  lists with roles, engine/compiler/date overrides. Re-running promote only
  touches mechanical fields and leaves curated ones intact.

Schema:
- edition is rebuilt: dll_sha1 loses UNIQUE, since the two-in-one disc shares
  one engine library across two games. fingerprint becomes the merge key and
  *_override columns hold curated values. The rebuild only runs on an empty
  table; otherwise it fails loudly rather than dropping curated data.
- script_fts, an FTS5 virtual table keyed by blob rather than by file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:17:52 +02:00
Patryk Gensch aaf2073ff7 Update Rex-EMoolator version 2026-08-20 21:40:10 +02:00
Patryk Gensch fd21d8c9a6 Initialized project with game ingesting 2026-08-11 00:31:56 +02:00