diff --git a/docs/frtk-table-api.md b/docs/frtk-table-api.md index b22c80d..bf92ab3 100644 --- a/docs/frtk-table-api.md +++ b/docs/frtk-table-api.md @@ -133,3 +133,28 @@ counters. It never exposes private memory or fingerprint material. The recruiting wrapper additionally reports `RECRUITING_ACTION_UNSUPPORTED` and `RECRUITING_HOURS_INSUFFICIENT` for its two domain-specific failures. + +## Live recruit class replacement POC + +With the game and Lua host running, one command generates a class with Brooks's +engine and replaces the existing live Player, Recruit, and fixed Player-name +rows: + +```powershell +cfb27lua live-class replace ` + --save "C:\path\to\DYNASTY-AUTOSAVE" ` + --brooks-root "C:\path\to\cfb27-dynasty-modding" ` + --seed poc-1 +``` + +The save is read only and supplies the existing row skeleton; no save is +rewritten and no live row is created. First name, last name, and hometown are +mandatory. A generated portrait/head asset is written when present. Gear is +skipped in this POC. + +Before the first write, the command requires one unique live Player surface, +Recruit surface, and Player string surface, then snapshots the complete class. +Writes use guarded batches of at most 32 operations with readback. Any later +failure rolls earlier batches back to that snapshot; an ambiguous mirror aborts +without guessing. Add `--dry-run` to generate, locate, and snapshot without +writing anything. diff --git a/docs/superpowers/plans/2026-07-14-live-recruit-class-replacement.md b/docs/superpowers/plans/2026-07-14-live-recruit-class-replacement.md new file mode 100644 index 0000000..a6d7069 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-live-recruit-class-replacement.md @@ -0,0 +1,268 @@ +# Live Recruit Class Replacement POC Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one command that runs Brooks's recruit generator against a read-only dynasty save and replaces the existing live recruit class, including names, with automatic verification and rollback. + +**Architecture:** A Brooks adapter converts the generator's `planApply` output into bit-masked Player/Recruit record patches plus fixed-slot Player strings. A locator finds the three contiguous live surfaces from save-derived anchors. A replacement service snapshots, applies, verifies, and rolls back guarded raw-memory batches; the CLI wires everything into one command. + +**Tech Stack:** Node.js CommonJS, existing `@cfb27/lua-hook` SDK memory APIs, Brooks's CommonJS generator modules, Node test runner. + +## Global Constraints + +- Never modify or resave the dynasty input. +- Replace existing Recruit and Player rows only; do not allocate rows. +- FirstName, LastName, and HomeTown are mandatory and must preflight before any write. +- Portrait/head writes are best-effort; gear is reported as skipped in the POC. +- Use guarded expected/replacement transactions with no more than 32 operations per host transaction. +- Snapshot all targeted live bytes before the first write and roll back completed batches after any failure. +- No UI, daemon, database, or generalized allocation API. + +--- + +### Task 1: Brooks Generator Adapter + +**Files:** +- Create: `packages/sdk/src/live-class-generator.cjs` +- Create: `packages/sdk/test/live-class-generator.test.cjs` +- Modify: `packages/sdk/index.cjs` + +**Interfaces:** +- Produces: `generateLiveClassPlan({ savePath, brooksRoot, seed }) -> Promise` +- `LiveClassPlan` contains `sourceRevision`, `classSize`, `playerRecordSize`, `recruitRecordSize`, `playerRows`, `recruitRows`, and `gearSkipped`. +- Each record row contains `{ row, beforeHex, maskHex, valueHex }`. +- Each player row also contains `strings: { FirstName, LastName, HomeTown, GenericHeadAssetName? }` and a 138-byte `beforeStringSlotHex`. + +- [ ] **Step 1: Write the failing adapter tests** + +Test injected Brooks dependencies that return two generated recruits. Assert that the adapter: + +```js +assert.equal(plan.classSize, 2); +assert.deepEqual(plan.playerRows[0].strings, { + FirstName: 'Marcus', LastName: 'Hill', HomeTown: 'Austin', +}); +assert.equal(plan.playerRows[0].maskHex.length, plan.playerRows[0].beforeHex.length); +assert.equal(plan.recruitRows[0].row, 30); +assert.equal(await hashFile(savePath), originalHash); +``` + +Also assert rejection for a missing name, an out-of-range row, unequal record lengths, a Brooks planning error, and any before/after save hash difference. + +- [ ] **Step 2: Run the focused tests and confirm red** + +Run: `node --test packages/sdk/test/live-class-generator.test.cjs` + +Expected: failure because `live-class-generator.cjs` does not exist. + +- [ ] **Step 3: Implement the adapter** + +Implement these exports: + +```js +async function generateLiveClassPlan({ savePath, brooksRoot, seed = 'default', dependencies = {} }) {} +function buildMaskedPatch(before, after) {} +function encodePlayerStringSlot(beforeSlot, strings) {} +``` + +The adapter must: + +1. hash the save before work; +2. dynamically load `runPreview`, `loadRecruitPool`, `planApply`, `openCollegeSave`, and `setRecordField` from `brooksRoot`; +3. run preview output inside `fs.mkdtemp()`; +4. call `planApply` and reject all collected errors; +5. clone each source record, apply only fields present in Brooks's write plan, and calculate `maskHex` from bytes changed between the two offline records; +6. read each existing 138-byte Player table2 slot and encode mandatory strings at offsets FirstName `0/17`, LastName `50/21`, and HomeTown `112/26`; optionally encode GenericHeadAssetName at `17/33`; +7. hash the save again and reject if it changed; +8. remove the temporary preview directory in `finally`. + +- [ ] **Step 4: Run focused and full SDK tests** + +Run: `node --test packages/sdk/test/live-class-generator.test.cjs` + +Expected: all focused tests pass. + +Run: `npm test` + +Expected: all repository tests pass. + +- [ ] **Step 5: Commit the adapter** + +```bash +git add packages/sdk/src/live-class-generator.cjs packages/sdk/test/live-class-generator.test.cjs packages/sdk/index.cjs +git commit -m "feat: adapt Brooks recruit classes for live writes" +``` + +### Task 2: Save-Derived Live Surface Locator + +**Files:** +- Create: `packages/sdk/src/live-class-locator.cjs` +- Create: `packages/sdk/test/live-class-locator.test.cjs` + +**Interfaces:** +- Consumes: record/string anchors from `LiveClassPlan`. +- Produces: `locateLiveClassSurfaces({ client, plan }) -> Promise<{ playerBase, recruitBase, playerStringsBase }>`. + +- [ ] **Step 1: Write the failing locator tests** + +Build a fake address space containing contiguous Player records, Recruit records, and 138-byte Player string slots. Assert: + +```js +assert.deepEqual(await locateLiveClassSurfaces({ client, plan }), { + playerBase: '0x10000000', + recruitBase: '0x20000000', + playerStringsBase: '0x30000000', +}); +``` + +Cover relocated bases, more than one initial scan hit with only one cross-row-valid candidate, no candidate, two fully valid candidates, short reads, and a save/live verification mismatch. + +- [ ] **Step 2: Run focused tests and confirm red** + +Run: `node --test packages/sdk/test/live-class-locator.test.cjs` + +Expected: failure because the locator module does not exist. + +- [ ] **Step 3: Implement exact-anchor location with cross-row verification** + +Implement: + +```js +async function locateContiguousSurface(client, { + rows, recordSize, anchorRow, anchorHex, verificationRows, +}) {} +async function locateLiveClassSurfaces({ client, plan }) {} +``` + +For each surface, scan one exact save-derived anchor, calculate `base = match - anchorRow * stride`, then read and exactly verify at least four spread-out rows. Require exactly one fully verified base. Player and Recruit strides come from the plan; Player strings always use 138 bytes. Reject all ambiguity rather than guessing between stale mirror copies. + +- [ ] **Step 4: Run locator and full tests** + +Run: `node --test packages/sdk/test/live-class-locator.test.cjs` + +Expected: all focused tests pass. + +Run: `npm test` + +Expected: all repository tests pass. + +- [ ] **Step 5: Commit the locator** + +```bash +git add packages/sdk/src/live-class-locator.cjs packages/sdk/test/live-class-locator.test.cjs +git commit -m "feat: locate live recruit class surfaces" +``` + +### Task 3: Guarded Replacement and Rollback + +**Files:** +- Create: `packages/sdk/src/live-class-replace.cjs` +- Create: `packages/sdk/test/live-class-replace.test.cjs` +- Modify: `packages/sdk/index.cjs` +- Modify: `packages/sdk/src/errors.cjs` + +**Interfaces:** +- Consumes: `LiveClassPlan`, located bases, and an SDK client. +- Produces: `replaceLiveClass({ client, plan, surfaces, generation, dryRun }) -> Promise`. +- `LiveClassResult` contains `status`, `classSize`, `batchesApplied`, `playerRowsWritten`, `recruitRowsWritten`, `nameSlotsWritten`, `optionalSkipped`, and `rollbackStatus`. + +- [ ] **Step 1: Write the failing replacement tests** + +Assert that preflight reads all rows before the first transaction, combines masks with current live bytes, writes no more than 32 operations per batch, rereads every committed batch, and reports gear as skipped. Inject a failure in batch two and assert that batch one is restored from the snapshot. Inject a rollback failure and assert a stable `LIVE_CLASS_ROLLBACK_FAILED` error. + +Core replacement rule: + +```js +replacement[i] = (current[i] & ~mask[i]) | (value[i] & mask[i]); +``` + +- [ ] **Step 2: Run focused tests and confirm red** + +Run: `node --test packages/sdk/test/live-class-replace.test.cjs` + +Expected: failure because the replacement module does not exist. + +- [ ] **Step 3: Implement preflight, batching, verification, and rollback** + +Implement: + +```js +async function replaceLiveClass({ client, plan, surfaces, generation, dryRun = false }) {} +function applyMask(current, mask, value) {} +function makeOperations(snapshot, plan, surfaces) {} +function chunkOperations(operations, maximum = 32) {} +``` + +Preflight must read every numeric record and Player string slot, validate row bounds and exact lengths, encode required names, and build an immutable rollback snapshot before any transaction. Each forward batch uses the snapshot bytes as `expectedHex`; each rollback batch uses the forward replacement as `expectedHex`. After every forward or rollback batch, reread and compare the complete written ranges. + +- [ ] **Step 4: Run replacement and full tests** + +Run: `node --test packages/sdk/test/live-class-replace.test.cjs` + +Expected: all focused tests pass. + +Run: `npm test` + +Expected: all repository tests pass. + +- [ ] **Step 5: Commit the replacement service** + +```bash +git add packages/sdk/src/live-class-replace.cjs packages/sdk/test/live-class-replace.test.cjs packages/sdk/index.cjs packages/sdk/src/errors.cjs +git commit -m "feat: replace live recruit classes with rollback" +``` + +### Task 4: One-Command CLI and Documentation + +**Files:** +- Modify: `packages/cli/src/main.cjs` +- Modify: `packages/cli/test/main.test.cjs` +- Modify: `docs/frtk-table-api.md` +- Modify: `package.json` + +**Interfaces:** +- Produces: `cfb27 live-class replace --save --brooks-root [--seed ] [--dry-run]`. + +- [ ] **Step 1: Write failing CLI tests** + +Assert exact parsing, required paths, dry-run propagation, one JSON result under `--json`, refusal of unknown flags, and sanitized errors. Use injected adapter/locator/replacer functions so tests never require a game or real save. + +- [ ] **Step 2: Run CLI tests and confirm red** + +Run: `node --test packages/cli/test/main.test.cjs` + +Expected: the new command assertions fail. + +- [ ] **Step 3: Wire the command** + +The handler must execute only this sequence: + +```js +const plan = await generateLiveClassPlan({ savePath, brooksRoot, seed }); +const status = await client.status(); +const surfaces = await locateLiveClassSurfaces({ client, plan }); +return replaceLiveClass({ client, plan, surfaces, generation: status.generation, dryRun }); +``` + +Document that the save is read-only, names are mandatory, gear is skipped in the POC, ambiguous live mirrors abort, and no in-game operation occurs unless the user invokes the command without `--dry-run`. + +- [ ] **Step 4: Run complete verification** + +Run: `npm run check` + +Expected: exit code 0. + +Run: `npm test` + +Expected: all tests pass. + +Run: `git diff --check` + +Expected: no output. + +- [ ] **Step 5: Commit the CLI POC** + +```bash +git add packages/cli/src/main.cjs packages/cli/test/main.test.cjs docs/frtk-table-api.md package.json +git commit -m "feat: add live recruit class replacement command" +``` diff --git a/docs/superpowers/specs/2026-07-14-live-recruit-class-replacement-design.md b/docs/superpowers/specs/2026-07-14-live-recruit-class-replacement-design.md new file mode 100644 index 0000000..8a47004 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-live-recruit-class-replacement-design.md @@ -0,0 +1,68 @@ +# Live Recruit Class Replacement POC + +## Goal + +Prove that Brooks's external recruit generator can replace the game's already-generated recruit class directly in live FrTk data without modifying or resaving the dynasty file. + +## Command + +Expose one command: + +```text +live-class replace --save --brooks-root +``` + +The command reads the save as a skeleton, runs Brooks's generator, and immediately applies the generated class to the running game. There is no user-reviewed intermediate plan. + +## Scope + +The POC must write: + +- existing `Player` rows: position, archetype, height, weight, body type, development trait, stars, ratings, physical abilities, mental abilities, home state, and pipeline; +- existing `Recruit` rows: quality modifier, ranks, and alternate positions; +- first name, last name, and hometown through a verified live string path. + +Portrait/head and gear/`CharacterVisuals` are best-effort. They may be reported as skipped without failing the core replacement. The POC does not add or remove recruit or player rows. + +## Data Flow + +1. Read the supplied save without writing it. +2. Use its existing recruit-to-player row pairs as Brooks's generation skeleton. +3. Run Brooks's generator from the existing local checkout, reset to a known `origin/main` revision before integration. +4. Normalize Brooks's generated writes in memory. +5. Discover the live `Player`, `Recruit`, and string surfaces and verify their identities, capacities, and generation. +6. Preflight every target row, field, value, and string before the first write. +7. Capture a rollback snapshot of all affected live records and strings. +8. Apply guarded batches, rereading each batch after it commits. +9. On any failure, stop and restore every completed batch from the snapshot. +10. Return a compact summary of generated recruits, fields written, optional surfaces skipped, and rollback status. + +## Safety Boundary + +- The save path is read-only; the command never calls a save writer. +- Names are mandatory. If the live string path cannot safely write all required names and hometowns, preflight fails before any mutation. +- All record changes carry expected old values and lifecycle generation guards. +- Player/Recruit row relationships must match the read-only save skeleton before writing. +- The operation is multi-batch rather than globally atomic, so rollback is mandatory. +- Managed or derived fields that fail reread verification abort the operation. + +## MVP Architecture + +- A thin Brooks adapter invokes the existing generator and converts `buildRecruitWrites` output into a normalized in-memory plan. +- A live-class service performs discovery, preflight, snapshot, guarded batching, verification, and rollback. +- A minimal CLI command wires the two together. +- No UI, daemon, persistent database, generalized allocation API, or recruit creation is included. + +## Verification + +Automated tests use synthetic Player, Recruit, and string mirrors to prove: + +- Brooks output maps to the expected existing row pairs; +- the save is never opened for writing; +- names are a hard preflight gate; +- guarded batches stop on stale data; +- a mid-operation failure restores earlier batches; +- successful rereads match the normalized generated class; +- unsupported portrait or gear writes are reported but do not fail the core operation. + +The POC is complete when one command can generate and transactionally replace a synthetic full class offline. Actual game execution is a separate user-controlled gate. diff --git a/package.json b/package.json index c8e6b91..768508f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packages/cli" ], "scripts": { - "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs", + "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs", "test": "node scripts/run-tests.cjs", "build:frtk-profile": "node scripts/build-frtk-profile.cjs", "pack:preview": "node scripts/package-release.cjs" diff --git a/packages/cli/src/args.cjs b/packages/cli/src/args.cjs index 4b34a28..fcf3906 100644 --- a/packages/cli/src/args.cjs +++ b/packages/cli/src/args.cjs @@ -47,6 +47,10 @@ function parseArgs(argv) { allowUnsupportedBuild: false, includeAllocationMetadata: false, allowExternalFile: false, + save: undefined, + brooksRoot: undefined, + seed: undefined, + dryRun: false, row: undefined, fields: [], }; @@ -62,6 +66,9 @@ function parseArgs(argv) { ['--max-pages', 'maxPages'], ['--context', 'context'], ['--row', 'row'], + ['--save', 'save'], + ['--brooks-root', 'brooksRoot'], + ['--seed', 'seed'], ]); for (let index = 0; index < argv.length; index += 1) { @@ -81,7 +88,7 @@ function parseArgs(argv) { } if (token === '--json' || token === '--follow' || token === '--allow-unsupported-build' || token === '--include-allocation-metadata' || - token === '--allow-external-file') { + token === '--allow-external-file' || token === '--dry-run') { if (seen.has(token)) throw usageError(`Duplicate option: ${token}`); seen.add(token); if (token === '--json') json = true; @@ -90,6 +97,7 @@ function parseArgs(argv) { else if (token === '--include-allocation-metadata') { options.includeAllocationMetadata = true; } + else if (token === '--dry-run') options.dryRun = true; else options.allowExternalFile = true; continue; } diff --git a/packages/cli/src/main.cjs b/packages/cli/src/main.cjs index 4ff29c2..90ed630 100644 --- a/packages/cli/src/main.cjs +++ b/packages/cli/src/main.cjs @@ -20,6 +20,18 @@ const TRANSACTION_ERROR_MESSAGES = Object.freeze({ INVALID_RESPONSE: 'Host returned an invalid writeTransaction response', }); +const LIVE_CLASS_ERROR_MESSAGES = Object.freeze({ + GAME_NOT_RUNNING: 'College Football 27 is not running', + HOST_NOT_READY: 'The Lua host is not ready', + UNSUPPORTED_BUILD: 'Live recruit replacement requires the supported game build', + SESSION_WRITES_DISABLED: 'Writes are disabled for this host session', + PIPE_TIMEOUT: 'The live recruit class request timed out', + LIVE_CLASS_PLAN_INVALID: 'The generated recruit class plan is invalid', + LIVE_CLASS_SURFACE_UNVERIFIED: 'Live recruit class memory could not be uniquely verified', + LIVE_CLASS_APPLY_FAILED: 'Live recruit class replacement failed and applied batches were rolled back', + LIVE_CLASS_ROLLBACK_FAILED: 'Live recruit class replacement failed and rollback could not be verified', +}); + const HELP = `cfb27lua [options] Commands: @@ -45,6 +57,8 @@ Commands: Load a profile and read typed fields by numeric Unique ID telemetry register Register structured telemetry type names + live-class replace --save --brooks-root + Generate with Brooks and replace the existing live recruit class Options: --game-dir College Football 27 directory @@ -66,6 +80,10 @@ Options: --allow-external-file Allow a FrTk profile outside .frtk or transaction JSON outside CWD --row Typed FrTk record row --field Typed FrTk field name; may be repeated + --save Read-only dynasty autosave used as the row skeleton + --brooks-root Brooks cfb27-dynasty-modding checkout + --seed Optional generator seed (default: default) + --dry-run Generate, locate, and snapshot without writing -h, --help Show this help`; const defaultIo = { @@ -112,6 +130,11 @@ function rejectMisplacedDeveloperOptions(command, positionals, options) { options.includeAllocationMetadata || undefined, ]; const frtkOptions = [options.row, options.fields.length ? options.fields : undefined]; + const liveClassOptions = [options.save, options.brooksRoot, options.seed, + options.dryRun || undefined]; + if (command !== 'live-class' && liveClassOptions.some((value) => value !== undefined)) { + throw usageError('Live class options are only valid for live-class replace'); + } if (command !== 'memory' && (scanOptions.some((value) => value !== undefined) || options.ranges.length || options.allowUnsupportedBuild)) { throw usageError('Memory diagnostic options are only valid for memory diagnostics; they are not valid for this command'); @@ -273,6 +296,14 @@ function printCommandSuccess(io, command, result, json) { } return; } + if (command === 'live-class replace') { + io.out(`Live recruit class: ${result.status}`); + io.out(`${result.classSize} recruits; ${result.batchesApplied} batches applied`); + if (result.optionalSkipped) { + io.out(`${result.optionalSkipped.portraits} portraits and ${result.optionalSkipped.gear} gear rows skipped`); + } + return; + } printSuccess(io, command, result, false); } @@ -285,6 +316,30 @@ function sanitizeTransactionError(error) { return Object.assign(new Error(TRANSACTION_ERROR_MESSAGES[code]), { code }); } +function sanitizeLiveClassError(error) { + if (error?.code === 'USAGE') return error; + const code = typeof error?.code === 'string' && + Object.hasOwn(LIVE_CLASS_ERROR_MESSAGES, error.code) + ? error.code + : 'LIVE_CLASS_PLAN_INVALID'; + return Object.assign(new Error(LIVE_CLASS_ERROR_MESSAGES[code]), { code }); +} + +function assertLiveClassStatus(status, dryRun) { + if (!status || status.ready !== true) { + throw Object.assign(new Error('The Lua host is not ready'), { code: 'HOST_NOT_READY' }); + } + if (status.supportedBuild !== true) { + throw Object.assign(new Error('The game build is unsupported'), { code: 'UNSUPPORTED_BUILD' }); + } + if (!dryRun && (status.writesAllowed !== true || status.sessionWritesDisabled === true)) { + throw Object.assign(new Error('Writes are disabled'), { code: 'SESSION_WRITES_DISABLED' }); + } + if (!Number.isSafeInteger(status.ticks) || status.ticks < 0) { + throw Object.assign(new Error('Host status is invalid'), { code: 'INVALID_RESPONSE' }); + } +} + async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo, @@ -467,6 +522,46 @@ async function main(argv, { } else { throw usageError('frtk requires profile validate, catalog discover/inspect, or records read'); } + } else if (command === 'live-class') { + const [operation, ...extra] = positionals; + if (operation !== 'replace' || extra.length) { + throw usageError('live-class requires replace'); + } + if (!options.save) throw usageError('--save is required for live-class replace'); + if (!options.brooksRoot) { + throw usageError('--brooks-root is required for live-class replace'); + } + if (options.seed !== undefined && (options.seed.length < 1 || options.seed.length > 128)) { + throw usageError('--seed must contain 1 to 128 characters'); + } + const unrelated = [options.gameDir, options.mmcDir, options.artifactsDir, + options.follow || undefined, options.after, options.pattern, options.mask, + options.maxMatches, options.maxPages, options.context, + options.ranges.length ? options.ranges : undefined, + options.allowUnsupportedBuild || undefined, + options.includeAllocationMetadata || undefined, + options.allowExternalFile || undefined, + options.row, options.fields.length ? options.fields : undefined]; + if (unrelated.some((value) => value !== undefined)) { + throw usageError('This option is not valid for live-class replace'); + } + const plan = await sdk.generateLiveClassPlan({ + savePath: path.resolve(cwd, options.save), + brooksRoot: path.resolve(cwd, options.brooksRoot), + seed: options.seed || 'default', + }); + const game = await sdk.discoverGame(); + const client = sdk.createClient({ pid: game.pid, timeoutMs: 10_000 }); + const status = await client.status(); + assertLiveClassStatus(status, options.dryRun); + const surfaces = await sdk.locateLiveClassSurfaces({ client, plan }); + result = await sdk.replaceLiveClass({ + client, + plan, + surfaces, + generation: status.ticks, + dryRun: options.dryRun, + }); } else { throw usageError(`Unknown command: ${command}`); } @@ -475,16 +570,20 @@ async function main(argv, { ? `${command} ${positionals[0]}` : command === 'frtk' ? `${command} ${positionals[0]} ${positionals[1]}` + : command === 'live-class' + ? `${command} ${positionals[0]}` : command; printCommandSuccess(io, displayCommand, result, json); return 0; } catch (error) { const safeError = parsed.command === 'memory' && parsed.positionals?.[0] === 'transact' ? sanitizeTransactionError(error) - : error; + : parsed.command === 'live-class' + ? sanitizeLiveClassError(error) + : error; printError(io, safeError, parsed.json === true, { - includeDetails: parsed.command !== 'frtk' || - safeError?.code === 'FRTK_DISCOVERY_TIMEOUT', + includeDetails: parsed.command !== 'live-class' && (parsed.command !== 'frtk' || + safeError?.code === 'FRTK_DISCOVERY_TIMEOUT'), }); return exitCodeFor(safeError); } diff --git a/packages/cli/test/main.test.cjs b/packages/cli/test/main.test.cjs index a7e01a9..0999923 100644 --- a/packages/cli/test/main.test.cjs +++ b/packages/cli/test/main.test.cjs @@ -931,3 +931,90 @@ test('developer-only options are rejected outside their exact diagnostic operati assert.match(output.stderr, /not valid/, argv.join(' ')); } }); + +test('live-class replace runs generation, status, location, and replacement in order', async () => { + const calls = []; + const plan = { classSize: 4101 }; + const surfaces = { playerBase: 'hidden', recruitBase: 'hidden', playerStringsBase: 'hidden' }; + const result = { + status: 'dry_run', classSize: 4101, plannedBatches: 385, batchesApplied: 0, + }; + const client = { + status: async () => { + calls.push(['status']); + return { + ready: true, supportedBuild: true, writesAllowed: true, + sessionWritesDisabled: false, ticks: 123, + }; + }, + }; + const sdk = { + generateLiveClassPlan: async (options) => { calls.push(['generate', options]); return plan; }, + discoverGame: async () => { calls.push(['discover']); return { pid: 42 }; }, + createClient: (options) => { calls.push(['client', options]); return client; }, + locateLiveClassSurfaces: async (options) => { calls.push(['locate', options]); return surfaces; }, + replaceLiveClass: async (options) => { calls.push(['replace', options]); return result; }, + }; + const { io, output } = memoryIo(); + const cwd = 'C:\\workspace'; + assert.equal(await main([ + 'live-class', 'replace', '--save', 'autosave', '--brooks-root', '..\\brooks', + '--seed', 'poc-1', '--dry-run', '--json', + ], { sdk, io, cwd }), 0); + + assert.deepEqual(calls, [ + ['generate', { + savePath: path.resolve(cwd, 'autosave'), + brooksRoot: path.resolve(cwd, '..\\brooks'), + seed: 'poc-1', + }], + ['discover'], + ['client', { pid: 42, timeoutMs: 10_000 }], + ['status'], + ['locate', { client, plan }], + ['replace', { client, plan, surfaces, generation: 123, dryRun: true }], + ]); + assert.deepEqual(JSON.parse(output.stdout), { + ok: true, + command: 'live-class replace', + result, + }); +}); + +test('live-class replace requires both paths and rejects misplaced options', async () => { + for (const argv of [ + ['live-class', 'replace', '--brooks-root', 'brooks'], + ['live-class', 'replace', '--save', 'autosave'], + ['status', '--seed', 'wrong-command'], + ['live-class', 'replace', '--save', 'autosave', '--brooks-root', 'brooks', '--wat'], + ]) { + const { io, output } = memoryIo(); + assert.equal(await main(argv, { sdk: {}, io }), 2, argv.join(' ')); + assert.match(output.stderr, /required|only valid|Unknown option/, argv.join(' ')); + } +}); + +test('live-class errors omit raw addresses and buffers', async () => { + const hostile = Object.assign(new Error('bad row at 0x7FF612340000: DEADBEEF'), { + code: 'LIVE_CLASS_APPLY_FAILED', + details: { address: '0x7FF612340000', bytesHex: 'DEADBEEF' }, + }); + const sdk = { + generateLiveClassPlan: async () => ({ classSize: 1 }), + discoverGame: async () => ({ pid: 42 }), + createClient: () => ({ status: async () => ({ + ready: true, supportedBuild: true, writesAllowed: true, + sessionWritesDisabled: false, ticks: 12, + }) }), + locateLiveClassSurfaces: async () => ({}), + replaceLiveClass: async () => { throw hostile; }, + }; + const { io, output } = memoryIo(); + assert.notEqual(await main([ + 'live-class', 'replace', '--save', 'autosave', '--brooks-root', 'brooks', '--json', + ], { sdk, io, cwd: 'C:\\workspace' }), 0); + assert.match(output.stdout, /LIVE_CLASS_APPLY_FAILED/); + assert.equal(output.stdout.includes('0x7FF612340000'), false); + assert.equal(output.stdout.includes('DEADBEEF'), false); + assert.equal(output.stdout.includes('bytesHex'), false); +}); diff --git a/packages/sdk/index.cjs b/packages/sdk/index.cjs index 2b38d0b..98a166a 100644 --- a/packages/sdk/index.cjs +++ b/packages/sdk/index.cjs @@ -21,6 +21,15 @@ const { CONTACT_ACTIONS, createLiveRecruitingService, } = require('./src/live-recruiting.cjs'); +const { + generateLiveClassPlan, +} = require('./src/live-class-generator.cjs'); +const { + locateLiveClassSurfaces, +} = require('./src/live-class-locator.cjs'); +const { + replaceLiveClass, +} = require('./src/live-class-replace.cjs'); module.exports = { ERROR_CODES, @@ -43,4 +52,7 @@ module.exports = { LIVE_RECRUITING_TABLES, CONTACT_ACTIONS, createLiveRecruitingService, + generateLiveClassPlan, + locateLiveClassSurfaces, + replaceLiveClass, }; diff --git a/packages/sdk/src/errors.cjs b/packages/sdk/src/errors.cjs index 19b21b5..a9b1c31 100644 --- a/packages/sdk/src/errors.cjs +++ b/packages/sdk/src/errors.cjs @@ -30,6 +30,10 @@ const ERROR_CODES = Object.freeze([ 'TOO_MANY_MATCHES', 'INSTALLATION_CONFLICT', 'BACKUP_VERIFICATION_FAILED', + 'LIVE_CLASS_PLAN_INVALID', + 'LIVE_CLASS_SURFACE_UNVERIFIED', + 'LIVE_CLASS_APPLY_FAILED', + 'LIVE_CLASS_ROLLBACK_FAILED', ]); class Cfb27HookError extends Error { diff --git a/packages/sdk/src/live-class-generator.cjs b/packages/sdk/src/live-class-generator.cjs new file mode 100644 index 0000000..654c892 --- /dev/null +++ b/packages/sdk/src/live-class-generator.cjs @@ -0,0 +1,280 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const PLAYER_STRING_SLOT_SIZE = 138; +const PLAYER_STRING_FIELDS = Object.freeze({ + FirstName: Object.freeze({ offset: 0, size: 17, required: true }), + GenericHeadAssetName: Object.freeze({ offset: 17, size: 33, required: false }), + LastName: Object.freeze({ offset: 50, size: 21, required: true }), + HomeTown: Object.freeze({ offset: 112, size: 26, required: false }), +}); + +function fail(message) { + const error = new Error(message); + error.code = 'LIVE_CLASS_PLAN_INVALID'; + return error; +} + +function toLiveMirrorHex(value) { + if (typeof value !== 'string' || !/^[0-9A-F]+$/.test(value) || + value.length % 8 !== 0) { + throw fail('live mirror values require complete aligned 32-bit words'); + } + const bytes = Buffer.from(value, 'hex'); + for (let offset = 0; offset < bytes.length; offset += 4) { + bytes.subarray(offset, offset + 4).reverse(); + } + return bytes.toString('hex').toUpperCase(); +} + +async function hashFile(filePath) { + const hash = crypto.createHash('sha256'); + for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk); + return hash.digest('hex'); +} + +function requireBuffer(value, size, label) { + if (!Buffer.isBuffer(value) || value.length !== size) { + throw fail(`${label} must be exactly ${size} bytes`); + } + return Buffer.from(value); +} + +function setMaskBits(mask, offset, length) { + if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 1 || + offset + length > mask.length * 8) { + throw fail('field metadata is outside the record'); + } + for (let bit = offset; bit < offset + length; bit += 1) { + mask[bit >> 3] |= 1 << (7 - (bit & 7)); + } +} + +function buildMaskedPatch(before, after, fields) { + if (!Buffer.isBuffer(before) || !Buffer.isBuffer(after) || before.length === 0 || + before.length !== after.length || !Array.isArray(fields) || fields.length === 0) { + throw fail('numeric patch requires equal non-empty records and at least one field'); + } + const mask = Buffer.alloc(before.length); + for (const field of fields) setMaskBits(mask, field.offset, field.length); + return Object.freeze({ + beforeHex: before.toString('hex').toUpperCase(), + maskHex: mask.toString('hex').toUpperCase(), + valueHex: after.toString('hex').toUpperCase(), + }); +} + +function encodePlayerStringSlot(beforeSlot, strings) { + const output = requireBuffer(beforeSlot, PLAYER_STRING_SLOT_SIZE, 'Player string slot'); + if (!strings || typeof strings !== 'object' || Array.isArray(strings)) { + throw fail('Player strings must be an object'); + } + for (const [name, definition] of Object.entries(PLAYER_STRING_FIELDS)) { + const value = strings[name]; + if (value == null && !definition.required) continue; + if (typeof value !== 'string' || (definition.required && value.length === 0)) { + throw fail(`${name} is required`); + } + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length > definition.size - 1) { + throw fail(`${name} is ${bytes.length} bytes; maximum is ${definition.size - 1}`); + } + output.fill(0, definition.offset, definition.offset + definition.size); + bytes.copy(output, definition.offset); + } + return output; +} + +function fieldMetadata(record, fieldNames) { + return fieldNames.map((name) => { + const metadata = record._fields && record._fields[name] && record._fields[name]._offset; + if (!metadata || !Number.isInteger(metadata.offset) || !Number.isInteger(metadata.length)) { + throw fail(`field ${name} lacks bit metadata`); + } + return { offset: metadata.offset, length: metadata.length }; + }); +} + +function mutateRecord(record, values, setRecordField) { + const names = Object.keys(values); + const before = Buffer.from(record._data); + const originals = names.map((name) => { + const field = record.fieldsArray.find((entry) => entry.key === name); + if (!field) throw fail(`record ${record.index} is missing ${name}`); + return [name, field.value]; + }); + const fields = fieldMetadata(record, names); + try { + for (const [name, value] of Object.entries(values)) setRecordField(record, name, value); + return { before, after: Buffer.from(record._data), fields }; + } finally { + for (const [name, value] of originals) setRecordField(record, name, value); + } +} + +function playerStringSlot(file, playerTable, row) { + const start = playerTable.offset + playerTable.header.table2StartIndex + + row * PLAYER_STRING_SLOT_SIZE; + const end = start + PLAYER_STRING_SLOT_SIZE; + if (!Buffer.isBuffer(file.unpackedFileContents) || start < 0 || + end > file.unpackedFileContents.length) { + throw fail(`Player row ${row} string slot is outside table2`); + } + return Buffer.from(file.unpackedFileContents.subarray(start, end)); +} + +async function openBrooksWriteTables(openCollegeSave, savePath) { + // Brooks's write map intentionally targets generic Field_N keys. Opening + // with useSchema:true renames many of those fields and makes planApply's + // otherwise valid output impossible to apply to the in-memory records. + const file = await openCollegeSave(savePath); + const playerTable = file.tables.find((table) => table.name === 'Player'); + const recruitTable = file.tables.find((table) => table.name === 'Recruit'); + if (!playerTable || !recruitTable) throw fail('Player or Recruit table is missing'); + if (!playerTable.recordsRead) await playerTable.readRecords(); + if (!recruitTable.recordsRead) await recruitTable.readRecords(); + return { file, playerTable, recruitTable }; +} + +async function defaultRunBrooks({ savePath, brooksRoot, seed, outDir, skeleton }) { + const load = (relativePath) => require(path.join(brooksRoot, relativePath)); + const { runPreview } = load('franchise-lab/generator/preview.js'); + const { loadRecruitPool } = load('franchise-lab/generator/join.js'); + const { planApply, setRecordField } = load('franchise-lab/generator/apply.js'); + const { openCollegeSave } = load('franchise-lab/college-franchise.js'); + + const previewResult = await runPreview({ save: savePath, seed, outDir, skeleton }); + const preview = JSON.parse(await fsp.readFile(previewResult.previewPath, 'utf8')); + const { pool } = await loadRecruitPool(savePath); + const planned = planApply(preview, pool); + if (planned.errors.length) throw fail(`Brooks plan rejected: ${planned.errors.join('; ')}`); + + const { file, playerTable, recruitTable } = + await openBrooksWriteTables(openCollegeSave, savePath); + + const players = []; + const recruits = []; + for (const write of planned.writes) { + const player = playerTable.records[write.playerRow]; + const recruit = recruitTable.records[write.recruitRow]; + if (!player || player.isEmpty || !recruit || recruit.isEmpty) { + throw fail(`Brooks targeted an empty row ${write.recruitRow}:${write.playerRow}`); + } + players.push({ + row: write.playerRow, + ...mutateRecord(player, write.playerFields, setRecordField), + beforeStringSlot: playerStringSlot(file, playerTable, write.playerRow), + strings: { ...write.playerStrings }, + }); + recruits.push({ + row: write.recruitRow, + ...mutateRecord(recruit, write.recruitFields, setRecordField), + }); + } + + return { + sourceRevision: execFileSync('git', ['-C', brooksRoot, 'rev-parse', 'HEAD'], { + encoding: 'utf8', windowsHide: true, + }).trim(), + playerRecordSize: playerTable.header.record1Size, + recruitRecordSize: recruitTable.header.record1Size, + players, + recruits, + gearSkipped: planned.writes.filter((write) => write.gear).length, + }; +} + +function normalizeRow(raw, recordSize, label) { + if (!raw || !Number.isInteger(raw.row) || raw.row < 0) throw fail(`${label} row is invalid`); + const before = requireBuffer(raw.before, recordSize, `${label} before record`); + const after = requireBuffer(raw.after, recordSize, `${label} after record`); + return { row: raw.row, ...buildMaskedPatch(before, after, raw.fields) }; +} + +function uniqueRows(rows, label) { + const found = new Set(); + for (const row of rows) { + if (found.has(row.row)) throw fail(`${label} row ${row.row} is duplicated`); + found.add(row.row); + } +} + +async function generateLiveClassPlan({ savePath, brooksRoot, seed = 'default', dependencies = {} }) { + if (typeof savePath !== 'string' || typeof brooksRoot !== 'string') { + throw fail('savePath and brooksRoot are required'); + } + const resolvedSave = path.resolve(savePath); + const resolvedBrooks = path.resolve(brooksRoot); + const beforeHash = await hashFile(resolvedSave); + const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cfb27-live-class-')); + const runBrooks = dependencies.runBrooks || defaultRunBrooks; + let raw; + let caught; + try { + raw = await runBrooks({ + savePath: resolvedSave, + brooksRoot: resolvedBrooks, + seed, + outDir, + skeleton: true, + }); + } catch (error) { + caught = error; + } finally { + await fsp.rm(outDir, { recursive: true, force: true }); + } + const afterHash = await hashFile(resolvedSave); + if (afterHash !== beforeHash) throw fail('Dynasty save file changed during live-class generation'); + if (caught) throw caught; + + if (!raw || !Number.isInteger(raw.playerRecordSize) || raw.playerRecordSize < 1 || + !Number.isInteger(raw.recruitRecordSize) || raw.recruitRecordSize < 1 || + !Array.isArray(raw.players) || !Array.isArray(raw.recruits) || + raw.players.length === 0 || raw.players.length !== raw.recruits.length) { + throw fail('Brooks returned an invalid class plan'); + } + const playerRows = raw.players.map((row) => { + const normalized = normalizeRow(row, raw.playerRecordSize, 'Player'); + const beforeStringSlot = requireBuffer( + row.beforeStringSlot, PLAYER_STRING_SLOT_SIZE, 'Player string slot', + ); + const desiredSlot = encodePlayerStringSlot(beforeStringSlot, row.strings); + return Object.freeze({ + ...normalized, + strings: Object.freeze({ ...row.strings }), + beforeStringSlotHex: beforeStringSlot.toString('hex').toUpperCase(), + stringValueHex: desiredSlot.toString('hex').toUpperCase(), + }); + }); + const recruitRows = raw.recruits.map((row) => + Object.freeze(normalizeRow(row, raw.recruitRecordSize, 'Recruit'))); + uniqueRows(playerRows, 'Player'); + uniqueRows(recruitRows, 'Recruit'); + + return Object.freeze({ + sourceRevision: String(raw.sourceRevision || 'unknown'), + seed, + classSize: playerRows.length, + playerRecordSize: raw.playerRecordSize, + recruitRecordSize: raw.recruitRecordSize, + playerRows: Object.freeze(playerRows), + recruitRows: Object.freeze(recruitRows), + gearSkipped: playerRows.length, + }); +} + +module.exports = { + PLAYER_STRING_FIELDS, + PLAYER_STRING_SLOT_SIZE, + buildMaskedPatch, + encodePlayerStringSlot, + generateLiveClassPlan, + openBrooksWriteTables, + toLiveMirrorHex, +}; diff --git a/packages/sdk/src/live-class-locator.cjs b/packages/sdk/src/live-class-locator.cjs new file mode 100644 index 0000000..007551a --- /dev/null +++ b/packages/sdk/src/live-class-locator.cjs @@ -0,0 +1,195 @@ +'use strict'; + +const { PLAYER_STRING_SLOT_SIZE, toLiveMirrorHex } = require('./live-class-generator.cjs'); + +function fail(message) { + const error = new Error(message); + error.code = 'LIVE_CLASS_SURFACE_UNVERIFIED'; + return error; +} + +function parseAddress(value) { + if (typeof value !== 'string' || !/^0x[0-9A-Fa-f]+$/.test(value)) { + throw fail('live surface returned an invalid address'); + } + return BigInt(value); +} + +function formatAddress(value) { + if (value < 0n) throw fail('live surface address underflowed'); + return `0x${value.toString(16).toUpperCase()}`; +} + +function validateRows(rows, recordSize, hexField, maskField, label) { + if (!Array.isArray(rows) || rows.length < 4 || !Number.isInteger(recordSize) || + recordSize < 1 || typeof hexField !== 'string') { + throw fail(`${label} locator requires at least four valid rows`); + } + for (const row of rows) { + if (!row || !Number.isInteger(row.row) || row.row < 0 || + typeof row[hexField] !== 'string' || + !/^[0-9A-F]+$/.test(row[hexField]) || row[hexField].length !== recordSize * 2 || + (maskField && (typeof row[maskField] !== 'string' || + !/^[0-9A-F]+$/.test(row[maskField]) || row[maskField].length !== recordSize * 2))) { + throw fail(`${label} locator row is malformed`); + } + } +} + +function spreadRows(rows) { + const indexes = [0, Math.floor((rows.length - 1) / 3), + Math.floor(((rows.length - 1) * 2) / 3), rows.length - 1]; + const selected = []; + const seen = new Set(); + for (const index of indexes) { + const row = rows[index]; + if (!seen.has(row.row)) { + selected.push(row); + seen.add(row.row); + } + } + for (const row of rows) { + if (selected.length >= 4) break; + if (!seen.has(row.row)) { + selected.push(row); + seen.add(row.row); + } + } + if (selected.length < 4) throw fail('live surface needs four distinct verification rows'); + return selected; +} + +function maskedEqual(actualHex, expectedHex, maskHex) { + if (!maskHex) return actualHex === expectedHex; + const actual = Buffer.from(actualHex, 'hex'); + const expected = Buffer.from(expectedHex, 'hex'); + const mask = Buffer.from(maskHex, 'hex'); + return actual.every((byte, index) => (byte & mask[index]) === (expected[index] & mask[index])); +} + +async function candidateMatches(client, base, verificationRows, recordSize, hexField, maskField) { + const ranges = verificationRows.map((row) => ({ + address: formatAddress(base + BigInt(row.row) * BigInt(recordSize)), + length: recordSize, + })); + let result; + try { + result = await client.readMemory({ ranges }); + } catch { + return false; + } + if (!result || !Array.isArray(result.ranges) || result.ranges.length !== ranges.length) { + return false; + } + for (let index = 0; index < ranges.length; index += 1) { + const actual = result.ranges[index]; + if (!actual || actual.length !== recordSize || + parseAddress(actual.address) !== parseAddress(ranges[index].address) || + !maskedEqual(actual.bytesHex, verificationRows[index][hexField], + maskField ? verificationRows[index][maskField] : undefined)) { + return false; + } + } + return true; +} + +async function locateContiguousSurfaceDetailed(client, { + rows, recordSize, hexField = 'beforeHex', maskField, label = 'surface', + preferredAllocationBase, +}) { + if (!client || typeof client.scanMemory !== 'function' || + typeof client.readMemory !== 'function') { + throw fail(`${label} locator requires memory scan and read support`); + } + validateRows(rows, recordSize, hexField, maskField, label); + const sorted = [...rows].sort((left, right) => left.row - right.row); + const anchor = rows[0]; + const scan = await client.scanMemory({ + patternHex: anchor[hexField], + maskHex: maskField ? anchor[maskField] : 'FF'.repeat(recordSize), + maxMatches: 64, + contextBefore: 0, + contextAfter: 0, + maxPages: 4096, + includeAllocationMetadata: true, + }); + if (!scan || scan.complete !== true || !Array.isArray(scan.matches)) { + throw fail(`${label} live surface scan was incomplete`); + } + const candidateBases = new Map(); + for (const match of scan.matches) { + const address = parseAddress(match.address); + const displacement = BigInt(anchor.row) * BigInt(recordSize); + if (address >= displacement) { + const base = address - displacement; + candidateBases.set(base.toString(), { + base, + allocationBase: typeof match.allocationBase === 'string' + ? parseAddress(match.allocationBase) : null, + allocationSize: Number.isSafeInteger(match.allocationSize) && match.allocationSize > 0 + ? match.allocationSize : null, + }); + } + } + const verified = []; + const verificationRows = spreadRows(sorted); + for (const candidate of candidateBases.values()) { + if (await candidateMatches(client, candidate.base, verificationRows, + recordSize, hexField, maskField)) { + verified.push(candidate); + } + } + if (verified.length === 0) throw fail(`${label} live surface was not found`); + let selected = verified; + if (selected.length > 1 && typeof preferredAllocationBase === 'bigint') { + selected = selected.filter((candidate) => + candidate.allocationBase === preferredAllocationBase); + } + if (selected.length !== 1) throw fail(`${label} live surface is ambiguous`); + return Object.freeze({ + base: formatAddress(selected[0].base), + allocationBase: selected[0].allocationBase, + allocationSize: selected[0].allocationSize, + }); +} + +async function locateContiguousSurface(client, options) { + return (await locateContiguousSurfaceDetailed(client, options)).base; +} + +async function locateLiveClassSurfaces({ client, plan }) { + if (!plan || !Array.isArray(plan.playerRows) || !Array.isArray(plan.recruitRows)) { + throw fail('live class plan is invalid'); + } + const mirrorRows = (rows) => rows.map((row) => ({ + ...row, + beforeHex: toLiveMirrorHex(row.beforeHex), + maskHex: toLiveMirrorHex(row.maskHex), + })); + const player = await locateContiguousSurfaceDetailed(client, { + rows: mirrorRows(plan.playerRows), + recordSize: plan.playerRecordSize, + label: 'Player', + }); + const recruit = await locateContiguousSurfaceDetailed(client, { + rows: mirrorRows(plan.recruitRows), + recordSize: plan.recruitRecordSize, + label: 'Recruit', + }); + const nextPlayerAllocation = player.allocationBase !== null && player.allocationSize !== null + ? player.allocationBase + BigInt(player.allocationSize) : undefined; + const playerStrings = await locateContiguousSurfaceDetailed(client, { + rows: plan.playerRows, + recordSize: PLAYER_STRING_SLOT_SIZE, + hexField: 'beforeStringSlotHex', + label: 'Player strings', + preferredAllocationBase: nextPlayerAllocation, + }); + return Object.freeze({ + playerBase: player.base, + recruitBase: recruit.base, + playerStringsBase: playerStrings.base, + }); +} + +module.exports = { locateContiguousSurface, locateLiveClassSurfaces }; diff --git a/packages/sdk/src/live-class-replace.cjs b/packages/sdk/src/live-class-replace.cjs new file mode 100644 index 0000000..bfd25cb --- /dev/null +++ b/packages/sdk/src/live-class-replace.cjs @@ -0,0 +1,251 @@ +'use strict'; + +const { Cfb27HookError } = require('./errors.cjs'); +const { PLAYER_STRING_SLOT_SIZE, toLiveMirrorHex } = require('./live-class-generator.cjs'); + +const READ_BATCH_SIZE = 64; +const WRITE_BATCH_SIZE = 32; +const ADDRESS = /^0x(?:0|[1-9A-F][0-9A-F]{0,15})$/; +const HEX = /^[0-9A-F]+$/; + +function fail(code, message) { + return new Cfb27HookError(code, message); +} + +function formatAddress(value) { + return `0x${value.toString(16).toUpperCase()}`; +} + +function rowAddress(base, row, stride) { + return formatAddress(BigInt(base) + BigInt(row) * BigInt(stride)); +} + +function isHex(value, bytes) { + return typeof value === 'string' && value.length === bytes * 2 && HEX.test(value); +} + +function validatePatchRows(rows, size, { strings = false } = {}) { + if (!Array.isArray(rows) || rows.length < 1 || !Number.isInteger(size) || size < 1) return false; + const seen = new Set(); + for (const row of rows) { + if (!row || !Number.isInteger(row.row) || row.row < 0 || seen.has(row.row) || + !isHex(row.beforeHex, size) || !isHex(row.maskHex, size) || + !isHex(row.valueHex, size)) return false; + if (strings && (!isHex(row.beforeStringSlotHex, PLAYER_STRING_SLOT_SIZE) || + !isHex(row.stringValueHex, PLAYER_STRING_SLOT_SIZE) || !row.strings || + typeof row.strings.FirstName !== 'string' || !row.strings.FirstName || + typeof row.strings.LastName !== 'string' || !row.strings.LastName || + (row.strings.HomeTown !== undefined && + (typeof row.strings.HomeTown !== 'string' || !row.strings.HomeTown)))) return false; + seen.add(row.row); + } + return true; +} + +function validateInputs({ client, plan, surfaces, generation, dryRun }) { + if (!client || typeof client.readMemory !== 'function' || + typeof client.writeTransaction !== 'function' || + !plan || !surfaces || !Number.isSafeInteger(generation) || generation < 0 || + typeof dryRun !== 'boolean' || !ADDRESS.test(surfaces.playerBase) || + !ADDRESS.test(surfaces.recruitBase) || !ADDRESS.test(surfaces.playerStringsBase) || + !Number.isInteger(plan.classSize) || plan.classSize < 1 || + plan.playerRows?.length !== plan.classSize || plan.recruitRows?.length !== plan.classSize || + !validatePatchRows(plan.playerRows, plan.playerRecordSize, { strings: true }) || + !validatePatchRows(plan.recruitRows, plan.recruitRecordSize)) { + throw fail('LIVE_CLASS_PLAN_INVALID', 'Live recruit class plan is invalid'); + } +} + +function applyMask(current, maskHex, valueHex) { + const mask = Buffer.from(maskHex, 'hex'); + const value = Buffer.from(valueHex, 'hex'); + const replacement = Buffer.alloc(current.length); + for (let index = 0; index < current.length; index += 1) { + replacement[index] = (current[index] & (~mask[index] & 0xFF)) | + (value[index] & mask[index]); + } + return replacement; +} + +function buildStringMask(strings) { + const mask = Buffer.alloc(PLAYER_STRING_SLOT_SIZE); + mask.fill(0xFF, 0, 17); + mask.fill(0xFF, 50, 71); + if (typeof strings.HomeTown === 'string' && strings.HomeTown) { + mask.fill(0xFF, 112, 138); + } + if (typeof strings.GenericHeadAssetName === 'string' && strings.GenericHeadAssetName) { + mask.fill(0xFF, 17, 50); + } + return mask.toString('hex').toUpperCase(); +} + +function buildDescriptors(plan, surfaces) { + const descriptors = []; + for (const row of plan.playerRows) { + descriptors.push({ + kind: 'player', + address: rowAddress(surfaces.playerBase, row.row, plan.playerRecordSize), + length: plan.playerRecordSize, + maskHex: toLiveMirrorHex(row.maskHex), + valueHex: toLiveMirrorHex(row.valueHex), + }); + } + for (const row of plan.recruitRows) { + descriptors.push({ + kind: 'recruit', + address: rowAddress(surfaces.recruitBase, row.row, plan.recruitRecordSize), + length: plan.recruitRecordSize, + maskHex: toLiveMirrorHex(row.maskHex), + valueHex: toLiveMirrorHex(row.valueHex), + }); + } + for (const row of plan.playerRows) { + descriptors.push({ + kind: 'names', + address: rowAddress(surfaces.playerStringsBase, row.row, PLAYER_STRING_SLOT_SIZE), + length: PLAYER_STRING_SLOT_SIZE, + maskHex: buildStringMask(row.strings), + valueHex: row.stringValueHex, + }); + } + return descriptors; +} + +function chunks(values, size) { + const result = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +async function readDescriptors(client, descriptors) { + const bytes = []; + for (const batch of chunks(descriptors, READ_BATCH_SIZE)) { + const result = await client.readMemory({ + ranges: batch.map((item) => ({ address: item.address, length: item.length })), + }); + if (!result || !Array.isArray(result.ranges) || result.ranges.length !== batch.length) { + throw fail('LIVE_CLASS_APPLY_FAILED', 'Live recruit class snapshot could not be verified'); + } + for (let index = 0; index < batch.length; index += 1) { + const range = result.ranges[index]; + if (!range || range.address !== batch[index].address || + range.length !== batch[index].length || !isHex(range.bytesHex, batch[index].length)) { + throw fail('LIVE_CLASS_APPLY_FAILED', 'Live recruit class snapshot could not be verified'); + } + bytes.push(Buffer.from(range.bytesHex, 'hex')); + } + } + return bytes; +} + +function makeOperations(descriptors, snapshots) { + const operations = []; + for (let index = 0; index < descriptors.length; index += 1) { + const descriptor = descriptors[index]; + const current = snapshots[index]; + const replacement = applyMask(current, descriptor.maskHex, descriptor.valueHex); + if (!current.equals(replacement)) { + operations.push({ + kind: descriptor.kind, + address: descriptor.address, + expectedHex: current.toString('hex').toUpperCase(), + replacementHex: replacement.toString('hex').toUpperCase(), + }); + } + } + return operations; +} + +function transactionOperations(batch, reverse = false) { + return batch.map((operation) => ({ + address: operation.address, + expectedHex: reverse ? operation.replacementHex : operation.expectedHex, + replacementHex: reverse ? operation.expectedHex : operation.replacementHex, + })); +} + +async function verifyBatch(client, batch, expectedKey) { + const actual = await readDescriptors(client, batch.map((operation) => ({ + address: operation.address, + length: operation[expectedKey].length / 2, + }))); + for (let index = 0; index < batch.length; index += 1) { + if (actual[index].toString('hex').toUpperCase() !== batch[index][expectedKey]) { + throw new Error('batch verification failed'); + } + } +} + +async function rollbackBatches(client, applied, generation) { + for (let index = applied.length - 1; index >= 0; index -= 1) { + const batch = applied[index]; + const rollbackNumber = applied.length - index; + await client.writeTransaction({ + transactionId: `live-class-${generation}-rollback-${rollbackNumber}`, + operations: transactionOperations(batch, true), + }); + await verifyBatch(client, batch, 'expectedHex'); + } +} + +function buildResult(plan, operations, status, batchesApplied, plannedBatches) { + const count = (kind) => operations.filter((operation) => operation.kind === kind).length; + return Object.freeze({ + status, + classSize: plan.classSize, + plannedBatches, + batchesApplied, + playerRowsWritten: count('player'), + recruitRowsWritten: count('recruit'), + nameSlotsWritten: count('names'), + optionalSkipped: Object.freeze({ + portraits: plan.playerRows.filter((row) => !row.strings.GenericHeadAssetName).length, + gear: Number.isInteger(plan.gearSkipped) ? plan.gearSkipped : plan.classSize, + }), + rollbackStatus: 'not_needed', + }); +} + +async function replaceLiveClass({ client, plan, surfaces, generation, dryRun = false }) { + validateInputs({ client, plan, surfaces, generation, dryRun }); + const descriptors = buildDescriptors(plan, surfaces); + let snapshots; + try { + snapshots = await readDescriptors(client, descriptors); + } catch (error) { + if (error?.code === 'LIVE_CLASS_APPLY_FAILED') throw error; + throw fail('LIVE_CLASS_APPLY_FAILED', 'Live recruit class snapshot failed before any writes'); + } + const operations = makeOperations(descriptors, snapshots); + const batches = chunks(operations, WRITE_BATCH_SIZE); + if (dryRun) return buildResult(plan, operations, 'dry_run', 0, batches.length); + + const applied = []; + try { + for (let index = 0; index < batches.length; index += 1) { + const batch = batches[index]; + await client.writeTransaction({ + transactionId: `live-class-${generation}-forward-${index + 1}`, + operations: transactionOperations(batch), + }); + applied.push(batch); + await verifyBatch(client, batch, 'replacementHex'); + } + } catch { + try { + await rollbackBatches(client, applied, generation); + } catch { + throw fail('LIVE_CLASS_ROLLBACK_FAILED', + 'Live recruit class write failed and automatic rollback could not be verified'); + } + throw fail('LIVE_CLASS_APPLY_FAILED', + 'Live recruit class write failed; every applied batch was rolled back and verified'); + } + + return buildResult(plan, operations, 'applied_verified', batches.length, batches.length); +} + +module.exports = { replaceLiveClass }; diff --git a/packages/sdk/test/live-class-generator.test.cjs b/packages/sdk/test/live-class-generator.test.cjs new file mode 100644 index 0000000..f11cd42 --- /dev/null +++ b/packages/sdk/test/live-class-generator.test.cjs @@ -0,0 +1,193 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); + +const { + buildMaskedPatch, + encodePlayerStringSlot, + generateLiveClassPlan, + openBrooksWriteTables, + toLiveMirrorHex, +} = require('../src/live-class-generator.cjs'); + +async function fixture(t) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cfb27-live-class-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const savePath = path.join(dir, 'DYNASTY-AUTOSAVE'); + await fs.writeFile(savePath, Buffer.from('read-only-save-fixture')); + return { dir, savePath }; +} + +function rawPlan(overrides = {}) { + const playerBefore = Buffer.alloc(8); + const playerAfter = Buffer.from(playerBefore); + playerAfter[0] = 0x12; + const recruitBefore = Buffer.alloc(4); + const recruitAfter = Buffer.from(recruitBefore); + recruitAfter[2] = 0x30; + return { + sourceRevision: 'abc123', + playerRecordSize: 8, + recruitRecordSize: 4, + players: [{ + row: 20, + before: playerBefore, + after: playerAfter, + fields: [{ offset: 0, length: 8 }], + beforeStringSlot: Buffer.alloc(138), + strings: { FirstName: 'Marcus', LastName: 'Hill', HomeTown: 'Austin' }, + }], + recruits: [{ + row: 30, + before: recruitBefore, + after: recruitAfter, + fields: [{ offset: 16, length: 4 }], + }], + gearSkipped: 0, + ...overrides, + }; +} + +test('normalizes Brooks output into masked numeric patches and mandatory names', async (t) => { + const { savePath } = await fixture(t); + const plan = await generateLiveClassPlan({ + savePath, + brooksRoot: path.dirname(savePath), + dependencies: { runBrooks: async () => rawPlan() }, + }); + + assert.equal(plan.classSize, 1); + assert.equal(plan.sourceRevision, 'abc123'); + assert.equal(plan.playerRows[0].row, 20); + assert.equal(plan.playerRows[0].maskHex, 'FF00000000000000'); + assert.equal(plan.playerRows[0].valueHex, '1200000000000000'); + assert.deepEqual(plan.playerRows[0].strings, { + FirstName: 'Marcus', LastName: 'Hill', HomeTown: 'Austin', + }); + assert.equal(plan.playerRows[0].beforeStringSlotHex.length, 276); + assert.equal(plan.recruitRows[0].row, 30); + assert.equal(plan.recruitRows[0].maskHex, '0000F000'); + assert.equal(plan.gearSkipped, 1); + assert.equal((await fs.readFile(savePath, 'utf8')), 'read-only-save-fixture'); +}); + +test('requests Brooks skeleton mode for every live class plan', async (t) => { + const { savePath } = await fixture(t); + let received; + await generateLiveClassPlan({ + savePath, + brooksRoot: path.dirname(savePath), + dependencies: { + runBrooks: async (options) => { + received = options; + return rawPlan(); + }, + }, + }); + + assert.equal(received.skeleton, true); +}); + +test('buildMaskedPatch marks complete declared fields, including unchanged bits', () => { + const patch = buildMaskedPatch( + Buffer.from('0000', 'hex'), + Buffer.from('8000', 'hex'), + [{ offset: 0, length: 4 }, { offset: 12, length: 4 }], + ); + assert.deepEqual(patch, { + beforeHex: '0000', + maskHex: 'F00F', + valueHex: '8000', + }); +}); + +test('converts save-order records into the live little-endian dword mirror', () => { + assert.equal(toLiveMirrorHex('0011223344556677'), '3322110077665544'); + assert.throws(() => toLiveMirrorHex('0011'), /32-bit words/); +}); + +test('encodes mandatory Player strings into fixed table2 subslots', () => { + const before = Buffer.alloc(138, 0x7f); + const slot = encodePlayerStringSlot(before, { + FirstName: 'A', LastName: 'Bee', HomeTown: 'Cedar Park', + }); + assert.equal(slot.subarray(0, 17).toString('hex'), `${Buffer.from('A').toString('hex')}00${'00'.repeat(15)}`); + assert.equal(slot.subarray(50, 71).toString('utf8').replace(/\0.*$/s, ''), 'Bee'); + assert.equal(slot.subarray(112, 138).toString('utf8').replace(/\0.*$/s, ''), 'Cedar Park'); + assert.equal(slot[17], 0x7f, 'optional head slot remains untouched'); +}); + +test('preserves the existing hometown when skeleton mode omits it', () => { + const before = Buffer.alloc(138, 0x7f); + before.fill(0, 112, 138); + Buffer.from('Nashville', 'utf8').copy(before, 112); + + const slot = encodePlayerStringSlot(before, { + FirstName: 'Solomon', LastName: 'Bennett', GenericHeadAssetName: 'Unique_Test_1', + }); + + assert.deepEqual(slot.subarray(112, 138), before.subarray(112, 138)); +}); + +test('opens Brooks records schema-less so Field_N write aliases remain available', async () => { + const calls = []; + const player = { name: 'Player', recordsRead: true }; + const recruit = { name: 'Recruit', recordsRead: true }; + const file = { tables: [player, recruit] }; + const result = await openBrooksWriteTables(async (...args) => { + calls.push(args); + return file; + }, 'save-path'); + assert.deepEqual(calls, [['save-path']]); + assert.deepEqual(result, { file, playerTable: player, recruitTable: recruit }); +}); + +test('rejects missing or oversized mandatory names before returning a plan', async (t) => { + const { savePath } = await fixture(t); + for (const strings of [ + { FirstName: '', LastName: 'Hill', HomeTown: 'Austin' }, + { FirstName: 'X'.repeat(17), LastName: 'Hill', HomeTown: 'Austin' }, + ]) { + await assert.rejects( + generateLiveClassPlan({ + savePath, + brooksRoot: path.dirname(savePath), + dependencies: { + runBrooks: async () => rawPlan({ + players: [{ ...rawPlan().players[0], strings }], + }), + }, + }), + /FirstName/, + ); + } +}); + +test('rejects malformed rows, Brooks errors, and any save mutation', async (t) => { + const { savePath } = await fixture(t); + const args = { savePath, brooksRoot: path.dirname(savePath) }; + + await assert.rejects(generateLiveClassPlan({ + ...args, + dependencies: { runBrooks: async () => rawPlan({ players: [{ ...rawPlan().players[0], row: -1 }] }) }, + }), /row/); + + await assert.rejects(generateLiveClassPlan({ + ...args, + dependencies: { runBrooks: async () => { throw new Error('generator exploded'); } }, + }), /generator exploded/); + + await assert.rejects(generateLiveClassPlan({ + ...args, + dependencies: { + runBrooks: async () => { + await fs.appendFile(savePath, 'changed'); + return rawPlan(); + }, + }, + }), /save file changed/i); +}); diff --git a/packages/sdk/test/live-class-locator.test.cjs b/packages/sdk/test/live-class-locator.test.cjs new file mode 100644 index 0000000..38bd9f3 --- /dev/null +++ b/packages/sdk/test/live-class-locator.test.cjs @@ -0,0 +1,191 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + locateContiguousSurface, + locateLiveClassSurfaces, +} = require('../src/live-class-locator.cjs'); +const { toLiveMirrorHex } = require('../src/live-class-generator.cjs'); + +const addr = (value) => `0x${BigInt(value).toString(16).toUpperCase()}`; + +function rowBytes(row, size, salt) { + const bytes = Buffer.alloc(size); + for (let index = 0; index < size; index += 1) bytes[index] = (row * 17 + index + salt) & 0xff; + return bytes; +} + +function makePlan() { + const rows = [2, 4, 7, 9, 12]; + return { + classSize: rows.length, + playerRecordSize: 8, + recruitRecordSize: 4, + playerRows: rows.map((row) => ({ + row, + beforeHex: rowBytes(row, 8, 3).toString('hex').toUpperCase(), + maskHex: 'FFFFFFFFFFFFFFFF', + beforeStringSlotHex: rowBytes(row, 138, 91).toString('hex').toUpperCase(), + })), + recruitRows: rows.map((row) => ({ + row, + beforeHex: rowBytes(row, 4, 47).toString('hex').toUpperCase(), + maskHex: 'FFFFFFFF', + })), + }; +} + +function surface(base, stride, rows, hexField, transform = false, allocation = {}) { + const maximum = Math.max(...rows.map((row) => row.row)); + const bytes = Buffer.alloc((maximum + 1) * stride, 0xee); + for (const row of rows) { + const value = transform ? toLiveMirrorHex(row[hexField]) : row[hexField]; + Buffer.from(value, 'hex').copy(bytes, row.row * stride); + } + return { base: BigInt(base), bytes, ...allocation }; +} + +function fakeClient(segments, options = {}) { + const scans = []; + const reads = []; + return { + scans, + reads, + async scanMemory(request) { + scans.push(request); + const pattern = Buffer.from(request.patternHex, 'hex'); + const mask = Buffer.from(request.maskHex, 'hex'); + const matches = []; + for (const segment of segments) { + for (let offset = 0; offset + pattern.length <= segment.bytes.length; offset += 1) { + const candidate = segment.bytes.subarray(offset, offset + pattern.length); + if (candidate.every((byte, index) => + (byte & mask[index]) === (pattern[index] & mask[index]))) { + const match = { address: addr(segment.base + BigInt(offset)) }; + if (request.includeAllocationMetadata && segment.allocationBase !== undefined) { + match.allocationBase = addr(segment.allocationBase); + match.allocationSize = segment.allocationSize; + } + matches.push(match); + } + } + } + return { supportedBuild: true, complete: true, scannedBytes: 1, matches }; + }, + async readMemory(request) { + reads.push(request); + if (options.shortRead) return { supportedBuild: true, ranges: [] }; + return { + supportedBuild: true, + ranges: request.ranges.map((range) => { + const start = BigInt(range.address); + const segment = segments.find((item) => + start >= item.base && start + BigInt(range.length) <= item.base + BigInt(item.bytes.length)); + if (!segment) return { address: range.address, length: range.length, bytesHex: '00'.repeat(range.length) }; + const offset = Number(start - segment.base); + return { + address: range.address, + length: range.length, + bytesHex: segment.bytes.subarray(offset, offset + range.length).toString('hex').toUpperCase(), + }; + }), + }; + }, + }; +} + +test('locates relocated Player, Recruit, and Player string surfaces', async () => { + const plan = makePlan(); + const client = fakeClient([ + surface(0x10000000, 8, plan.playerRows, 'beforeHex', true), + surface(0x20000000, 4, plan.recruitRows, 'beforeHex', true), + surface(0x30000000, 138, plan.playerRows, 'beforeStringSlotHex'), + ]); + assert.deepEqual(await locateLiveClassSurfaces({ client, plan }), { + playerBase: '0x10000000', + recruitBase: '0x20000000', + playerStringsBase: '0x30000000', + }); + assert.equal(client.scans.length, 3); + assert.ok(client.reads.every((request) => request.ranges.length >= 4)); +}); + +test('locates numeric surfaces from full pre-write records instead of sparse write masks', async () => { + const plan = makePlan(); + for (const row of plan.playerRows) row.maskHex = 'FF00000000000000'; + for (const row of plan.recruitRows) row.maskHex = 'F0000000'; + const client = fakeClient([ + surface(0x11000000, 8, plan.playerRows, 'beforeHex', true), + surface(0x22000000, 4, plan.recruitRows, 'beforeHex', true), + surface(0x33000000, 138, plan.playerRows, 'beforeStringSlotHex'), + ]); + + assert.deepEqual(await locateLiveClassSurfaces({ client, plan }), { + playerBase: '0x11000000', + recruitBase: '0x22000000', + playerStringsBase: '0x33000000', + }); + assert.equal(client.scans[0].maskHex, 'FF'.repeat(plan.playerRecordSize)); + assert.equal(client.scans[1].maskHex, 'FF'.repeat(plan.recruitRecordSize)); +}); + +test('selects the duplicate string surface adjacent to the verified Player allocation', async () => { + const plan = makePlan(); + const client = fakeClient([ + surface(0x51000100, 8, plan.playerRows, 'beforeHex', true, { + allocationBase: 0x51000000n, allocationSize: 0x200000, + }), + surface(0x53000000, 4, plan.recruitRows, 'beforeHex', true, { + allocationBase: 0x53000000n, allocationSize: 0x100000, + }), + surface(0x51200070, 138, plan.playerRows, 'beforeStringSlotHex', false, { + allocationBase: 0x51200000n, allocationSize: 0x180000, + }), + surface(0x62000070, 138, plan.playerRows, 'beforeStringSlotHex', false, { + allocationBase: 0x62000000n, allocationSize: 0x180000, + }), + ]); + + assert.deepEqual(await locateLiveClassSurfaces({ client, plan }), { + playerBase: '0x51000100', + recruitBase: '0x53000000', + playerStringsBase: '0x51200070', + }); + assert.ok(client.scans.every((request) => request.includeAllocationMetadata === true)); +}); + +test('selects the only candidate whose spread-out rows match', async () => { + const plan = makePlan(); + const real = surface(0x40000000, 8, plan.playerRows, 'beforeHex'); + const decoy = surface(0x50000000, 8, plan.playerRows, 'beforeHex'); + decoy.bytes[4 * 8] ^= 0xff; + const client = fakeClient([real, decoy]); + assert.equal(await locateContiguousSurface(client, { + rows: plan.playerRows, + recordSize: 8, + hexField: 'beforeHex', + label: 'Player', + }), '0x40000000'); +}); + +test('fails closed on missing, ambiguous, or short-read surfaces', async () => { + const plan = makePlan(); + await assert.rejects(locateContiguousSurface(fakeClient([]), { + rows: plan.playerRows, recordSize: 8, hexField: 'beforeHex', label: 'Player', + }), /not found/); + + await assert.rejects(locateContiguousSurface(fakeClient([ + surface(0x60000000, 8, plan.playerRows, 'beforeHex'), + surface(0x70000000, 8, plan.playerRows, 'beforeHex'), + ]), { + rows: plan.playerRows, recordSize: 8, hexField: 'beforeHex', label: 'Player', + }), /ambiguous/); + + await assert.rejects(locateContiguousSurface(fakeClient([ + surface(0x80000000, 8, plan.playerRows, 'beforeHex'), + ], { shortRead: true }), { + rows: plan.playerRows, recordSize: 8, hexField: 'beforeHex', label: 'Player', + }), /not found/); +}); diff --git a/packages/sdk/test/live-class-replace.test.cjs b/packages/sdk/test/live-class-replace.test.cjs new file mode 100644 index 0000000..9ef05ba --- /dev/null +++ b/packages/sdk/test/live-class-replace.test.cjs @@ -0,0 +1,228 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { encodePlayerStringSlot, toLiveMirrorHex } = require('../src/live-class-generator.cjs'); +const { replaceLiveClass } = require('../src/live-class-replace.cjs'); + +function address(base, row, stride) { + return `0x${(BigInt(base) + BigInt(row * stride)).toString(16).toUpperCase()}`; +} + +function makeFixture({ count = 12, failForwardBatch = 0, failRollback = false } = {}) { + const playerRecordSize = 8; + const recruitRecordSize = 4; + const stringSize = 138; + const surfaces = { + playerBase: '0x100000', + recruitBase: '0x200000', + playerStringsBase: '0x300000', + }; + const memory = new Map(); + const playerRows = []; + const recruitRows = []; + + for (let row = 0; row < count; row += 1) { + const playerBefore = Buffer.alloc(playerRecordSize, 0x10 + row); + const playerMask = Buffer.from('FF00000000000000', 'hex'); + const playerValue = Buffer.alloc(playerRecordSize); + playerValue[0] = 0x80 + row; + const recruitBefore = Buffer.alloc(recruitRecordSize, 0x20 + row); + const recruitMask = Buffer.from('00FF0000', 'hex'); + const recruitValue = Buffer.alloc(recruitRecordSize); + recruitValue[1] = 0x40 + row; + const stringBefore = Buffer.alloc(stringSize, 0x2E); + const strings = { + FirstName: `First${row}`, + LastName: `Last${row}`, + HomeTown: `Town${row}`, + ...(row === 0 ? { GenericHeadAssetName: 'head_generated' } : {}), + }; + const stringValue = encodePlayerStringSlot(stringBefore, strings); + + playerRows.push({ + row, + beforeHex: playerBefore.toString('hex').toUpperCase(), + maskHex: playerMask.toString('hex').toUpperCase(), + valueHex: playerValue.toString('hex').toUpperCase(), + beforeStringSlotHex: stringBefore.toString('hex').toUpperCase(), + stringValueHex: stringValue.toString('hex').toUpperCase(), + strings, + }); + recruitRows.push({ + row, + beforeHex: recruitBefore.toString('hex').toUpperCase(), + maskHex: recruitMask.toString('hex').toUpperCase(), + valueHex: recruitValue.toString('hex').toUpperCase(), + }); + memory.set(address(surfaces.playerBase, row, playerRecordSize), + Buffer.from(toLiveMirrorHex(playerBefore.toString('hex').toUpperCase()), 'hex')); + memory.set(address(surfaces.recruitBase, row, recruitRecordSize), + Buffer.from(toLiveMirrorHex(recruitBefore.toString('hex').toUpperCase()), 'hex')); + memory.set(address(surfaces.playerStringsBase, row, stringSize), Buffer.from(stringBefore)); + } + + const initial = snapshot(memory); + const events = []; + let forwardBatch = 0; + const client = { + async readMemory({ ranges }) { + events.push({ type: 'read', ranges: ranges.length }); + return { + supportedBuild: true, + ranges: ranges.map((range) => { + const bytes = memory.get(range.address); + if (!bytes || bytes.length !== range.length) throw new Error('unexpected test range'); + return { + address: range.address, + length: range.length, + bytesHex: bytes.toString('hex').toUpperCase(), + }; + }), + }; + }, + async writeTransaction(transaction) { + const rollback = transaction.transactionId.includes('-rollback-'); + events.push({ type: rollback ? 'rollback' : 'write', operations: transaction.operations.length }); + if (rollback && failRollback) throw new Error('rollback rejected'); + if (!rollback) { + forwardBatch += 1; + if (forwardBatch === failForwardBatch) throw new Error('forward rejected'); + } + for (const operation of transaction.operations) { + const current = memory.get(operation.address); + assert.equal(current.toString('hex').toUpperCase(), operation.expectedHex); + } + for (const operation of transaction.operations) { + memory.set(operation.address, Buffer.from(operation.replacementHex, 'hex')); + } + return { + transactionId: transaction.transactionId, + status: 'applied_verified', + operations: transaction.operations.map((unused, index) => ({ + index, applied: true, verified: true, + })), + }; + }, + }; + + return { + client, + events, + initial, + memory, + surfaces, + plan: { + classSize: count, + playerRecordSize, + recruitRecordSize, + playerRows, + recruitRows, + gearSkipped: count, + }, + }; +} + +function snapshot(memory) { + return [...memory.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, value.toString('hex').toUpperCase()]); +} + +test('snapshots the full class, applies batches of at most 32, and verifies every batch', async () => { + const fixture = makeFixture(); + const result = await replaceLiveClass({ + client: fixture.client, + plan: fixture.plan, + surfaces: fixture.surfaces, + generation: 7, + }); + + assert.equal(result.status, 'applied_verified'); + assert.equal(result.classSize, 12); + assert.equal(result.batchesApplied, 2); + assert.equal(result.playerRowsWritten, 12); + assert.equal(result.recruitRowsWritten, 12); + assert.equal(result.nameSlotsWritten, 12); + assert.deepEqual(result.optionalSkipped, { portraits: 11, gear: 12 }); + assert.equal(result.rollbackStatus, 'not_needed'); + assert.ok(fixture.events.filter((event) => event.type === 'write') + .every((event) => event.operations <= 32)); + const firstWrite = fixture.events.findIndex((event) => event.type === 'write'); + assert.equal(fixture.events.slice(0, firstWrite).reduce((sum, event) => sum + event.ranges, 0), 36); + + const firstPlayer = fixture.memory.get('0x100000'); + assert.equal(firstPlayer[3], 0x80); + assert.equal(firstPlayer[0], 0x10, 'unmanaged player bytes are preserved'); + const firstStrings = fixture.memory.get('0x300000'); + assert.equal(firstStrings.subarray(0, 17).toString('utf8').replace(/\0.*$/s, ''), 'First0'); + assert.equal(firstStrings.subarray(17, 50).toString('utf8').replace(/\0.*$/s, ''), 'head_generated'); + const secondStrings = fixture.memory.get(address('0x300000', 1, 138)); + assert.ok(secondStrings.subarray(17, 50).every((byte) => byte === 0x2E), + 'missing optional portrait leaves the live bytes untouched'); +}); + +test('dry-run performs the complete snapshot without writing', async () => { + const fixture = makeFixture(); + const result = await replaceLiveClass({ + client: fixture.client, + plan: fixture.plan, + surfaces: fixture.surfaces, + generation: 8, + dryRun: true, + }); + + assert.equal(result.status, 'dry_run'); + assert.equal(result.batchesApplied, 0); + assert.equal(result.plannedBatches, 2); + assert.equal(fixture.events.some((event) => event.type === 'write'), false); + assert.deepEqual(snapshot(fixture.memory), fixture.initial); +}); + +test('skeleton plans may omit hometown and leave its live bytes unmanaged', async () => { + const fixture = makeFixture(); + const first = fixture.plan.playerRows[0]; + delete first.strings.HomeTown; + const hostileValue = Buffer.from(first.stringValueHex, 'hex'); + hostileValue.fill(0x58, 112, 138); + first.stringValueHex = hostileValue.toString('hex').toUpperCase(); + + await replaceLiveClass({ + client: fixture.client, + plan: fixture.plan, + surfaces: fixture.surfaces, + generation: 9, + }); + + const firstStrings = fixture.memory.get('0x300000'); + assert.ok(firstStrings.subarray(112, 138).every((byte) => byte === 0x2E)); +}); + +test('a later forward failure rolls every successful batch back to the live snapshot', async () => { + const fixture = makeFixture({ failForwardBatch: 2 }); + await assert.rejects( + replaceLiveClass({ + client: fixture.client, + plan: fixture.plan, + surfaces: fixture.surfaces, + generation: 9, + }), + (error) => error.code === 'LIVE_CLASS_APPLY_FAILED' && /rolled back/i.test(error.message), + ); + assert.deepEqual(snapshot(fixture.memory), fixture.initial); + assert.equal(fixture.events.filter((event) => event.type === 'rollback').length, 1); +}); + +test('reports a distinct hard failure when automatic rollback cannot be verified', async () => { + const fixture = makeFixture({ failForwardBatch: 2, failRollback: true }); + await assert.rejects( + replaceLiveClass({ + client: fixture.client, + plan: fixture.plan, + surfaces: fixture.surfaces, + generation: 10, + }), + (error) => error.code === 'LIVE_CLASS_ROLLBACK_FAILED', + ); +});