Workspace rules, target build, release chain, and elevation: see ../CLAUDE.md.
agent/: plain JavaScript Frida injects into the game.api/: the Java contract mods compile against, no runtime dependencies;api/internalis the loader's side.api-kotlin/: Kotlin sugar overapi.zygote/: the JVM side that loads mod jars and dispatches events.protocolowns injection, agent bundling, JVM startup, routing, and the verdict deadline.launcherowns player-facing startup and packaging. Never move their duties into the agent, API, or zygote.- A mod is one verified fat jar that packs its runtime dependencies and never the loader API (see
../build/CLAUDE.md).
- After code, address, or hook changes run all five:
python tools/addr.pygradlew buildpython tools/hooksafe.pynode tests/buildcheck.jspython tests/replay.py
tools/hooksafe.pyneedspefileandcapstone;tests/buildcheck.jsneeds Node.- A fresh checkout has no
agent/src/gen/addr.js. Runpython tools/addr.pyfirst; without it the host installs no hooks. hooksafe.pylooks inD:\SteamLibrary\steamapps\common\Sacred Goldby default; pass an executable or game directory otherwise. When it finds no game it printsskippedand exits 0. A skip is not evidence of safety.hooksafe.pywarnings are not failures.getLocalHero,expWrite, andskillPointsrelocate a branch, andskillPointscarries EAX across the patch; these are known and shipping.tests/replay.pyneeds builtapiandzygotejars and built mod jars from../mods/*/build/sacred-mod/(or a directory argument).no mod jars foundwith a nonzero exit is a failed prerequisite, not a pass.- replay enables
tracer,old-huge-potions, andall-my-runes. Only an in-game test proves Sacred commits a requested change. tests/buildcheck.jsis the only no-game test that runs agent JavaScript:gen/addr.jsplus10-core.jsin one Nodevm. Keep tested code within the QuickJS feature set.tests/inject_python.pyinjects the same bundle with the installed frida-python, separating a Frida-version failure from a hook failure. It answers every ASK with no change.- Never add a Java toolchain. The build uses
options.release = 21so Gradle downloads no JDK.api-kotlinsetsjvmTarget = JVM_21itself because Kotlin ignoresoptions.release. - Configuration cache runs with
problems=fail. Keep tasks compatible.
tools/addr.pytakesmappings.jsonfrom, in order: a command-line path,$CODERPACK_MAPPINGS, sibling../mappings, then GitHub at the ref in.mappings-ref, cached underbuild/mappings/.tools/paths.pyowns this order. Put a tag or commit in.mappings-reffor a reproducible build.agent/signatures.jsonis generated. Write it only withpython tools/hooksafe.py --signatures [path]against a realpureHD.exe. Never edit its bytes; a false signature warns on the correct game and trains users to ignore warnings.addr.pycopies the signatures intogen/addr.jsasBUILD;10-core.jscompares them with the running process at attach.- Keep a signature mismatch a warning. The agent still hooks at the current RVAs. Refusing would also block an unrecorded build that works. Stock
Sacred.exediffers at every site, so its hooks can land in unrelated code. - The agent checks instruction bytes, never version metadata. The fingerprint is the whole set of sites, since several signatures are the same generic SEH prologue. Version reading belongs to the launcher.
- A mod jar carries
META-INF/declaration.toml.id,entrypoint, andapiare required by zygote;loaderis optional. - Both ranges use Maven notation, parsed by
Ranges. A bare value means exactly that version, unlike Maven; this is deliberate and keeps old descriptors working. - Zygote refuses a missing, malformed, or incompatible
apibefore any mod code runs. Never assume compatibility; that turns a refusal into aNoSuchMethodErrorduring dispatch. loaderis checked against<game>/launcher/VERSION. Noloaderfield means no restriction. NoVERSIONfile (a hand-built checkout) skips the check.- The API contract number lives in three repositories that cannot import each other:
Api.VERSIONhere,Verifier.APIinbuild,mods.APIinlauncher. Raise all three together. Theapi-contractjob in.github/workflows/build.ymlfails when they differ; update it if a constant is renamed. - Range parsing exists three times:
Rangeshere,Rangesinbuild's verifier,pininlauncher. Their shared test corpus is the specification. Add every new case to all three.
- The JVM main thread reads the protocol and completes
RESreplies at once. Mod code runs only on the singlesal-dispatchdaemon thread. Never merge the two; a mod that calls the game from a listener would deadlock. - The dispatch queue is bounded. Ordinary events are dropped when it is full, with periodic warnings. An
ASKis enqueued with a blocking put and never dropped. - On
BYEor EOF, zygote drains for up to two seconds, then callsSystem.exit(0)so a mod's non-daemon thread cannot keep the JVM alive. getContext().getGame()commands wait up to two seconds forRES. Timeout or failure logs a warning and returns an empty result.- Verdict deadline and its real timing: see
../protocol/CLAUDE.md. Keep listeners on decidable events short. - Frames travel on two named pipes from
--pipe:<base>.inand<base>.out.Pipe.overopens them in that order because the host waits in that order. Why one pipe per direction: see../protocol/CLAUDE.md. - Without
--pipe,Pipe.overStdiouses stdout; test harnesses use this.Main.claimStdoutpointsSystem.outat stderr before the first mod loads, on both transports. Never routePipethroughSystem.out. - Each mod jar gets its own
URLClassLoader. - Unregistering a mod drops its listeners, calls
onUnload(logs, never rethrows), drops whatonUnloadregistered, then closes its class loader. A mod whoseonLoadthrows is removed the same way. Mods.shutdowncalls everyonUnloadnewest first on one thread with a three-second limit, then closes the log. It leaves class loaders open, because a shutdown hook may still load a class.- Zygote's own
Registryis the wire-name table.ModContextnames the API'sRegistryin full for that reason.
docs/EVENTS.mdis the event contract: mutations, the fold, dispatch order, vetoes,last(),MONITOR. Read it before changingBusor an event.- A listener returns
voidto observe, or exactly its event'sMutationto decide.Bus.registerchecks this and the linter checks it earlier.Foldis the only thing that writes to an event. - Zygote creates the entry point through
api.internal.ModBinding, which binds theContextto the thread around the no-argument constructor.getContext()then works in field initialisers. Any other instance, includingnewin a test, throwsIllegalStateException. - Every public accessor in
apiis a JavaBean getter (getX(),isX()), because Kotlin sees only those as properties. Records are final classes with getters. Static factories,get(key),size(),iterator(),find(...), andlast()keep their names. - Every collection the API returns is unmodifiable.
context.logwrites to<game>/logs/mods.logthrough a shared writer that never blocks or throws into the caller; a full queue drops and counts.- A wire event without a typed registry entry reaches mods as
Unknown. When the agent sends a new wire name, add it toRegistryand toRegistryTest. - Each agent command lives in the module that owns its data, so
--skipof a module removes its commands and Java sees an empty answer. - Every API package has
@ParametersAreNonnullByDefaultinpackage-info.java. Mark nullable parameters, and mark every reference return@Nonnullor@Nullable. - Keep
com.google.code.findbugs:jsr305compileOnly, out of the POM and out of mod jars.
api-kotlinadds no capability. Every declaration forwards toapi; registration helpers areinline.- Keep its package
dev.ancaria.coderpack.ktx. The linter'sContentscheck refuses any class underdev/ancaria/coderpack/api/in a mod jar, so a package there makes every Kotlin mod unbuildable. - Keep
apiacompileOnlyApidependency so it stays off the runtime classpath Shadow packs. The POM sayscompile; a Maven consumer must declareapiasprovided. - The loader never provides
api-kotlin. A mod that wants it declares itimplementationand packs it. - Never add a Kotlin
SacredMod. Kotlin mods extend the Java class and overrideonLoad(). - Never add an extension property that renames an API reader; a member shadows it. Add a getter to the Java class instead.
- Keep one
onwithmutate { }. Never split it into overloads by lambda return type: Kotlin coerces any lambda to() -> Unitand would silently drop a returned mutation. - Raising
Api.VERSIONneeds no change here. The module moves with the API artifact version. build's end-to-end test compiles a generated Kotlin mod against a stub of this module. A declaration a template uses must exist in both.
Every rule here comes from a reproduced crash or corruption.
- Never write a game address by hand. It comes from
mappingsthroughtools/addr.py. - Run
tools/hooksafe.pybefore shipping a hook. EveryFATALis a hard stop. - Never hook an instruction shorter than five bytes when a branch targets the next instruction. Frida's patch spills forward and the branch lands inside the trampoline. The four-byte skill write at
+0x1827DAkilled every character with an empty skill slot;70-skills.jshooks one instruction later. - Never split a flag-setting instruction from the branch that reads its flags. The old gold hook put
cmp [eax+0xc], ebxinside the patch and itsjeoutside, and crashed on every world load. - Avoid mid-function hooks where EAX, ECX, or EDX is loaded before the patch and read after it. Three such sites failed; two AddGold sites returned with EDI destroyed; the level write at
+0x185EBEcrashed while loading a save. Prefer a function entry or sampling on the tick, as60-gold.jsand80-level.jsdo. - Use
onLeaveonly at a function entry. Mid-function,[esp]is not a return address. The--tracewrapper addsonLeaveonly when the hook already has one. - Never attach to a writer that runs once per object during world load.
cObjectManager::loadunpacks thousands of objects; the attribute recalculation writers at+0x179B09..killed Frida's JIT memory (Faulting module: unknown). A player check inside the callback does not reduce the call volume. Hook load and save entries, as40-session.jsdoes. - Never request a verdict while the game is loading.
isLoading()in10-core.jsturns the ask into a plain event. - All agent files share one JavaScript scope. The host concatenates
gen/addr.jsand the numbered files in filename order; a duplicate helper name silently overwrites. Put shared helpers in10-core.jsand give others module-specific names. Keep"module": "none"inagent/jsconfig.json. - Snapshot a
NativePointerfromretvalorthis.context.*withsnapPtrbefore storing it; it can alias a live register.p === nullnever detects NULL; uselive(). - Frida runs QuickJS: use
globalThis, neverglobal; no DOM, no npm. AglobalThisguard cannot detect a second injection, so10-core.jsrefuses a double install by checking for0xE9or0xCCat known sites. - Route integer verdict fields through
asked()in20-bus.js. It validates, falls back on parse failure, and caps aboveINT32_MAX, because a boosted value can overflow before the game's own clamp. - Count calls before trusting a write hook, with
research/artifacts/probes/count_calls.py. A combat-art hook that looked safe after one click ran 80,000 times. - Do not touch the anti-cheat XOR mirrors unless a mod changed the mirrored field. Experience, gold, and level are mirrored behind the protected page at
[0x182DDDC]. Boost the delta at the function entry; rewriting the committed total makes the checker reset the field to 1. - Call the game's own function instead of writing its field when one exists. For
thiscall, Frida takesthisas the first declared argument. - An item's type ID exists at
+0x10and+0x118. The game reads+0x118; writing only+0x10changes nothing visible.
- Closing the host does not reliably unload the agent. Restart the game after changing a hook.
Faulting module: pureHD.exeplus an offset gives an RVA and usually points at the hook.Faulting module: unknownmeans Frida JIT memory and often a hook that is too hot.docs/RUNNING.mdcovers build, launch, and crash bisection.docs/STYLE.mddefines style.
tools/version.ps1writes the version togradle.properties, the Javadoc inApi.java, and the three READMEs.- A release carries
api.jar,zygote.jar, andagent.zip.agent.zipincludesgen/addr.js; onlyprotocolconsumes it, to build without a coderpack checkout. - CI uploads
api,api-kotlin, andzygoteto Maven Central before creating the GitHub release.api-kotlinreaches mods only through Central, never as a release asset. - The Central task is
nmcpPublishAggregationToCentralPortal.nmcpZipAggregationbuilds the same bundle locally without uploading; use it to inspect a release. - A Central artifact can never be deleted.
publishingTypestaysUSER_MANAGEDuntil a deployment has been seen to be right (see../CLAUDE.md). - Signing reads
SIGNING_KEYandSIGNING_PASSWORD. Without them the sign tasks are skipped, so a checkout without secrets still builds and publishes to Maven Local.