Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ sudo = "0.6.0"
proc_mem = "0.1.6"
is_elevated = "0.1.2"
pelite = "0.10"
winapi = { version = "0.3", features = ["psapi", "processthreadsapi", "handleapi", "winnt"] }
winapi = { version = "0.3", features = ["psapi", "processthreadsapi", "handleapi", "winnt", "memoryapi", "errhandlingapi", "impl-default"] }

[target.'cfg(target_os = "macos")'.dependencies]
mach2 = "0.4"
Expand Down
423 changes: 423 additions & 0 deletions NOTES.md

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions __test__/scanner-logic.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import test from "ava";

// These tests exercise the napi exports that DON'T need a live Arena
// process — they test error handling, input validation, and the
// pure-logic paths of the scanner functions.

let napi;
try {
napi = await import("../index.js");
} catch {
napi = null;
}

const hasNapi = napi !== null;

// --- Error handling for missing processes ---

test("readMtgaCards with nonexistent process returns error", (t) => {
if (!hasNapi) return t.pass("napi module not built");
const result = napi.readMtgaCards("nonexistent_process_99999");
t.truthy(result.error, "Should return error for missing process");
});

test("readMtgaInventory with nonexistent process returns error", (t) => {
if (!hasNapi) return t.pass("napi module not built");
const result = napi.readMtgaInventory("nonexistent_process_99999");
t.truthy(result.error, "Should return error for missing process");
});

// --- Mono scanner error handling ---

test("readMtgaCardsMono with nonexistent process returns error", (t) => {
if (!hasNapi) return t.pass("napi module not built");
const result = napi.readMtgaCardsMono("nonexistent_process_99999");
t.truthy(result.error, "Should return error for missing process");
});

test("readMtgaInventoryMono with nonexistent process returns error", (t) => {
if (!hasNapi) return t.pass("napi module not built");
const result = napi.readMtgaInventoryMono("nonexistent_process_99999", 0, 0);
t.truthy(result.error, "Should return error for missing process");
});

// Probe functions (probeHeapForI32Pair, probeMonoClass, readMonoBytes)
// are debug-only tools that may not be built on all platforms. Skip.

// --- Return shape validation (when process exists but no data) ---

test("readMtgaCards returns object with cards array or error", (t) => {
if (!hasNapi) return t.pass("napi module not built");
const result = napi.readMtgaCards("MTGA");
t.is(typeof result, "object");
t.true(
Array.isArray(result.cards) || typeof result.error === "string",
"Should return { cards: [] } or { error: string }",
);
if (result.cards) {
for (const card of result.cards.slice(0, 5)) {
t.is(typeof card.cardId, "number");
t.is(typeof card.quantity, "number");
t.true(card.cardId > 0);
t.true(card.quantity >= 1 && card.quantity <= 4);
}
}
});

test("readMtgaInventory returns object with expected fields or error", (t) => {
if (!hasNapi) return t.pass("napi module not built");
const result = napi.readMtgaInventory("MTGA");
t.is(typeof result, "object");
if (!result.error) {
t.is(typeof result.wcCommon, "number");
t.is(typeof result.wcUncommon, "number");
t.is(typeof result.wcRare, "number");
t.is(typeof result.wcMythic, "number");
t.is(typeof result.gold, "number");
t.is(typeof result.gems, "number");
t.is(typeof result.vaultProgress, "number");
t.true(result.vaultProgress >= 0 && result.vaultProgress <= 100);
}
});

62 changes: 62 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,67 @@ export declare function getInstanceField(address: number, fieldName: string): an
export declare function getStaticField(classAddress: number, fieldName: string): any
export declare function getDictionary(address: number): DictionaryData
export declare function readData(processName: string, fields: Array<string>): any
/**
* Signature-based card-collection reader. Scans the MTGA process
* heap for a `Dictionary<int, int>` object whose contents match
* the shape of an Arena player collection (enough entries, keys in
* the Arena card-id range, values in the quantity range) and
* returns the list of (cardId, quantity) entries.
*
* This is a macOS-only path added as a local patch: the
* `readData` walker starting from PAPA / WrapperController turned
* out to be too fragile against current Arena builds (IL2CPP
* metadata layout drift, runtime-class-vs-metadata-class
* indirection, inconsistent CLASS_NAME offsets on runtime-allocated
* class structs). The signature scan sidesteps every one of those
* by searching for the only dictionary in the process whose entries
* all look like real card entries.
*
* Returns a JSON array of `{ "cardId": int, "quantity": int }`
* objects on success, or `{ "error": string }` on any failure.
*/
export declare function readMtgaCards(processName: string): any
/**
* Inventory reader. Returns the current player's wildcard counts
* plus currency and vault progress, read directly from the
* `ClientPlayerInventory` singleton in Arena's memory.
*
* Returns `{ wcCommon, wcUncommon, wcRare, wcMythic, gold, gems,
* vaultProgress }` on success, `{ error }` on failure.
*
* `vaultProgress` is a number in `0.0 – 100.0` matching Arena's UI
* exactly (e.g. `58.9` when the UI shows "Vault: 58.9%"). The raw
* field is stored as an 8-byte `double` in the C# class, not an
* int — NOTES / IL2CPP_RESEARCH_SUMMARY.md were wrong about this.
*
* Set `MTGA_DEBUG_INVENTORY=1` for verbose stderr diagnostics (class
* location, field dump, candidate counts).
*/
export declare function readMtgaInventory(processName: string): any
/**
* Mono-backend card-collection reader. Targets Arena processes running
* the Mono scripting backend (Windows native or Wine). Pass the process
* name or path fragment (e.g. "MTGA.exe" for Wine).
*/
export declare function readMtgaCardsMono(processName: string): any
/**
* Mono-backend inventory reader.
* Pass known_gold and known_gems (visible in Arena's UI) for exact
* anchoring. Pass 0 for both to use the generic scanner (less reliable).
*/
export declare function readMtgaInventoryMono(processName: string, knownGold: number, knownGems: number): any
/** Debug: probe a MonoClass struct to find the name field offset. */
export declare function probeMonoClass(processName: string, classAddress: string): any
/**
* Debug: read raw bytes from a Mono Arena process at a given address.
* Returns hex string. Used for discovering Mono struct layouts.
*/
export declare function readMonoBytes(processName: string, address: string, length: number): any
/**
* Debug probe: search heap for two adjacent i32 values and dump context.
* Use to discover field offsets on Mono.
* Example: probeHeapForI32Pair("MTGA.exe", 1825, 610) finds gold+gems.
*/
export declare function probeHeapForI32Pair(processName: string, valA: number, valB: number): any
export declare function readClass(processName: string, address: number): any
export declare function readGenericInstance(processName: string, address: number): any
9 changes: 8 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}

const { isAdmin, findProcess, init, close, isInitialized, getAssemblies, getAssemblyClasses, getClassDetails, getInstance, getInstanceField, getStaticField, getDictionary, readData, readClass, readGenericInstance } = nativeBinding
const { isAdmin, findProcess, init, close, isInitialized, getAssemblies, getAssemblyClasses, getClassDetails, getInstance, getInstanceField, getStaticField, getDictionary, readData, readMtgaCards, readMtgaInventory, readMtgaCardsMono, readMtgaInventoryMono, probeMonoClass, readMonoBytes, probeHeapForI32Pair, readClass, readGenericInstance } = nativeBinding

module.exports.isAdmin = isAdmin
module.exports.findProcess = findProcess
Expand All @@ -325,5 +325,12 @@ module.exports.getInstanceField = getInstanceField
module.exports.getStaticField = getStaticField
module.exports.getDictionary = getDictionary
module.exports.readData = readData
module.exports.readMtgaCards = readMtgaCards
module.exports.readMtgaInventory = readMtgaInventory
module.exports.readMtgaCardsMono = readMtgaCardsMono
module.exports.readMtgaInventoryMono = readMtgaInventoryMono
module.exports.probeMonoClass = probeMonoClass
module.exports.readMonoBytes = readMonoBytes
module.exports.probeHeapForI32Pair = probeHeapForI32Pair
module.exports.readClass = readClass
module.exports.readGenericInstance = readGenericInstance
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/mono/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod offsets;
pub mod type_definition;
pub mod field_definition;
pub mod pe_reader;
pub mod scanner;

pub use reader::MonoBackend;
pub use offsets::MonoOffsets;
Loading