Metadata-Version: 2.5
Name: dm2-tools
Version: 0.8.0
Summary: Python bindings for the Dungeon Master II asset parsers (over the dm2-ffi C ABI)
Project-URL: Homepage, https://git.lordran.net/jqueuniet/dm2-tools
Project-URL: Repository, https://git.lordran.net/jqueuniet/dm2-tools
Project-URL: Changelog, https://git.lordran.net/jqueuniet/dm2-tools/src/branch/main/CHANGELOG.md
Project-URL: Issues, https://git.lordran.net/jqueuniet/dm2-tools/issues
License-Expression: MIT OR Apache-2.0
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Keywords: bindings,dungeon-master,gamedev,parser
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Games/Entertainment
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: test
Requires-Dist: pytest; extra == 'test'
Description-Content-Type: text/markdown

# dm2-tools (Python bindings)

Pythonic bindings for the *Dungeon Master II: The Legend of Skullkeep* asset
parsers, layered over the `dm2-ffi` C ABI via `ctypes`. Every function takes
`bytes` in and returns `bytes` / `dict` / `str` / `bool` out; failures raise
`dm2_tools.Dm2Error`.

## Install

```bash
pip install dm2-tools
```

Python 3.11 or newer. Wheels are published for x86-64 Linux (manylinux 2.28)
and x86-64 Windows; each carries the native library, so no Rust toolchain is
needed, and one wheel per platform covers every supported Python — the library
is loaded through `ctypes` rather than linked against a specific interpreter.

There is no source distribution. On macOS and other architectures, install
from a checkout of [the repository](https://git.lordran.net/jqueuniet/dm2-tools)
instead:

```bash
pip install ./bindings/python
```

That runs a hatchling build hook which invokes
`cargo build -p dm2-ffi --release` against the workspace and bundles the
resulting cdylib (`libdm2_ffi.dylib` / `libdm2_ffi.so` / `dm2_ffi.dll`) into
the wheel under `dm2_tools/_lib/`. **A Rust toolchain must be installed and on
`PATH`** for that path.

### Development / override

For local iteration without reinstalling, the module also looks for the
native library at:

1. `dm2_tools/_lib/<lib>` (bundled, present after a real install)
2. the path in the `DM2_FFI_LIB` environment variable, if set
3. `<repo-root>/target/release/<lib>` (the plain `cargo build` output — used
   automatically when running the package straight out of a checkout)

Set `DM2_FFI_LIB=/path/to/libdm2_ffi.so` (or `.dylib` / `.dll`) to point at a
specific build.

## Usage

```python
import dm2_tools

with dm2_tools.SaveGame(save_bytes) as save:  # no dungeon.dat / graphics.dat
    gameplay = save.gameplay()                  # -> dict
    gameplay["champions"][0]["cur_hp"] = 999
    save.set_gameplay(gameplay)
    edited = save.to_bytes()                    # -> bytes
```

JSON-returning functions parse the C ABI's UTF-8 JSON payload with
`json.loads` and return a plain `dict`; JSON-consuming functions accept a
plain `dict` and serialize it with `json.dumps` before calling in.

## API

| Function | Signature | Notes |
|---|---|---|
| `version()` | `() -> str` | Native library version (matches this package's version). |
| `save_info_json(data)` | `bytes -> dict` | Lossy, human-readable gameplay-only savegame view. |
| `skill_level(experience, skill)` | `(list[int], int) -> int` | Level of skill `skill` (0–19) from a champion's 20 experience values. |
| `skill_experience_for_level(experience, skill, level)` | `(list[int], int, int) -> int \| None` | Least storable experience giving `skill` exactly `level`, or `None`. |
| `music_detect(data)` | `bytes -> str` | Sniff a standalone music file's container format. |
| `music_hmp_to_smf(data)` | `bytes -> bytes` | Convert a DOS HMI/HMP stream to a Standard MIDI File. |
| `music_render_mod_wav(data)` | `bytes -> bytes` | Render an Amiga ProTracker module to a stereo WAV. |
| `music_snd_to_wav(data)` | `bytes -> bytes` | Decode a Mac `'snd '` resource payload to a mono WAV. |
| `ftl_info_json(data)` | `bytes -> dict` | Small summary of an FTL 68k module container. |
| `ftl_roundtrip_ok(data)` | `bytes -> bool` | `True` if the FTL module re-emits byte-exact. |
| `resfork_list(data)` | `bytes -> list[dict]` | List a Mac resource fork's resources (`type_code`, `id`, `name`, `payload_len`). |
| `resfork_extract(data, type_code, id)` | `(bytes, bytes\|str, int) -> bytes` | Extract one resource's payload by `(type_code, id)`. `type_code` may be 4 raw bytes or a 4-character `str` (encoded as Mac Roman). |

`graphics.dat` is read through `GraphicsArchive(data)`, which parses the
file once. Records are
addressed by their `index` in `entries()`; `find(cls1, cls2, cls3, cls4,
language=None)` looks one up by class tuple (`None` when absent). Options
are keyword arguments, `None` keeping the default, as in the wasm
`GraphicsArchive` table: `header()`, `entries()`, `find(…)`,
`labels(language=None)`, `item_catalog(language=None)`,
`skill_title(skill, level, language=None)`, `image(index,
transparent=False, style=None, light=None)`, `icon(frames,
frame_ms=None, transparent=False, style=None, light=None)`,
`sound(index)`, `music(index)`, `font(index, spacing=None)`,
`text(index)`, `text_image(text)`, `palette(index)`, `sequences(cls1, cls2)`,
`animation(cls1, cls2, sequence, view, frame_ms=None,
style=None, light=None)`. An unknown
option or a record of the wrong kind raises. Use it as a context manager or call `close()`:

```python
with dm2_tools.GraphicsArchive(data) as g:
    png = g.image(g.find(0x05, 0x00, 0x01, 0x01), transparent=True)
```

`DungeonFile(data)` parses a `dungeon.dat` once, as `DungeonFile` in the
wasm README; `DungeonFile.from_json(dump)` rebuilds one from a
`to_json()` dump. Methods: `to_json()`, `to_bytes()`, `skproject_json()`,
`map_layout(map)`, `level_layout(level)`, `map_png(map, graphics, scale=None,
creatures=None, fill=None)`, `level_png(level, graphics, …)`,
`map_chips(map, graphics, fill=None, language=None)`,
`level_chips(level, graphics, …)`, `graphics` being a `GraphicsArchive`:

```python
with dm2_tools.GraphicsArchive(gdat) as g, dm2_tools.DungeonFile(dungeon) as d:
    png = d.level_png(1, g, scale=2)
```

`SaveGame(data, dungeon, graphics)` decodes a save once against a
`DungeonFile` and a `GraphicsArchive`, then reads and edits it in place,
as `SaveGame` in the wasm README; `SaveGame.from_json(dump, dungeon,
graphics)` loads a `to_json()` dump. Without the files (`SaveGame(data)`,
`SaveGame.from_json(dump)`) only gameplay, bytes and field limits work
until `attach(dungeon, graphics)`; `has_context()` tells which. It keeps
the archive, so no method takes one: `to_json()`, `to_bytes()`, `gameplay()`,
`set_gameplay(gameplay)`, `inventory(language=None)`, `container(loc,
language=None)`, `move_item(src, dst)`, `swap_items(a, b)`,
`copy_item(src, dst)`, `clear_item(loc)`, `put_in_container(src,
container)`, `take_from_container(container, index, dst)`,
`create_item(dst, item)`, `preview_item(dst, item, language=None)`,
`field_limits()`. A refused edit raises and changes nothing:

```python
with dm2_tools.SaveGame(save, d, g) as s:
    s.move_item("1:hand-right", "cursor")
    edited = s.to_bytes()
```

All exceptions raised by the above are `dm2_tools.Dm2Error`.

## Testing

```bash
python -m pytest -q
```

Tests that need workspace fixtures look for them at `<repo-root>/fixtures/`
(override with `DM2_FIXTURES_DIR`); anything whose fixture isn't present
skips cleanly rather than failing.

## Scope

Out of scope for this package, intentionally deferred:

- Publishing to PyPI.
- Prebuilt / multi-platform wheels (CI-built manylinux, macOS universal2,
  Windows, …). Installing always compiles the cdylib locally via Cargo.
