diff --git a/README.md b/README.md index 195a7fc..76d93a1 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,12 @@ In the repository root: - **[CLAUDE.md](CLAUDE.md)** - Build instructions, testing guidelines, common tasks - **[ROADMAP.md](ROADMAP.md)** - Strategic vision and development phases +- **[docs/api-stability.md](docs/api-stability.md)** - 1.0 compatibility policy (value semantics, bulk-edit convention, subinterpreter exclusion) +- **[docs/threading-model.md](docs/threading-model.md)** - Threading contract for off-main-thread access via `mcrfpy.lock()` - **[roguelike_tutorial/](roguelike_tutorial/)** - Complete roguelike tutorial implementations +> **Note:** Running mcrfpy in a Python subinterpreter is unsupported in 1.x; the module declares `m_size = -1` and will refuse or misbehave. See [#220](https://dev.ffwf.net/forgejo/john/McRogueFace/issues/220). + ## Build Requirements - C++17 compiler (GCC 7+ or Clang 5+) diff --git a/docs/api-stability.md b/docs/api-stability.md new file mode 100644 index 0000000..bacea75 --- /dev/null +++ b/docs/api-stability.md @@ -0,0 +1,93 @@ +# API Stability Policy (1.0 Contract) + +This document records the compatibility promises that freeze at McRogueFace 1.0. +Each item below is a **semantic decision that cannot be changed compatibly after +1.0** and was ratified in the 2026-07-02 pre-1.0 memory-model review. Additive, +opt-in APIs may still be introduced after 1.0; the guarantees here constrain +what those additions may assume. + +The public `mcrfpy` surface (types, methods, properties, enum members, +functions, singletons, and each property's inferred type / read-only flag) is +guarded against accidental drift by +`tests/unit/api_surface_snapshot_test.py`, which diffs the live module against +a committed golden file. That test is the mechanical enforcement of this policy; +any intentional change to the surface must be a reviewed re-baseline of the +golden file. + +## Value semantics for Color and Vector (#326) + +`mcrfpy.Color` and `mcrfpy.Vector` are **value types, forever.** They store +their data inline (no shared references), and every property that returns a +Color or Vector returns a **fresh copy**. This matches how `sf::Color` / +`sf::Vector2f` behave in C++ and carries zero implementation risk. Write-through +proxy views were considered and rejected: they would freeze dangling-parent +lifetime and identity questions into the contract. + +The direct consequence is that mutating a component of a color/vector obtained +from a property does **not** affect the original — it mutates a throwaway copy: + +```python +frame.fill_color.r = 255 # NO-OP: mutates a temporary copy, then discards it +``` + +The two supported idioms are: + +```python +# 1. Read-modify-writeback +c = frame.fill_color # copy +c.r = 255 # mutate the copy +frame.fill_color = c # write the whole value back + +# 2. Whole-value assignment +frame.fill_color = mcrfpy.Color(255, 0, 0) +``` + +This behavior is stated normatively in the `Color` and `Vector` class +docstrings and will not change in the 1.x series. + +## Bulk-edit convention for writable views (#328) + +Future zero-copy **writable** views over cell/buffer data (numpy / buffer +protocol) are exposed **only** through an `edit()` context manager: + +```python +with layer.edit() as view: + view[...] = ... # write freely +# on __exit__, the affected state is conservatively invalidated +# (whole-layer markDirty / full TCOD resync + generation bump as applicable) +``` + +Forgetting to synchronize is impossible by construction, which applies the +project's fail-early principle to the API surface. The engine performs **no +automatic change detection**; invalidation is triggered unconditionally on +`__exit__`. + +- **Read-only** zero-copy views (for example `DiscreteMap.mask()`) may still be + exposed directly, with no `edit()` ceremony, because they cannot violate + engine invariants. +- **No always-writable raw view + `mark_dirty()` primitives will be shipped.** + If a genuine need for persistent writable views emerges after 1.0, such + primitives can be added additively without breaking the `edit()` idiom. + +This convention is the pattern that the buffer-protocol work must follow: +ColorLayer/TileLayer buffers (#335), related view APIs (#334), and any future +`grid.walkable` array view (which depends on the GridData SoA refactor, #332). + +## Subinterpreters are out of scope for 1.x (#330) + +Running mcrfpy in a Python subinterpreter is unsupported in 1.x; the module +declares `m_size = -1` and will refuse or misbehave. See #220. + +The module uses single-phase initialization with global state (static +`PyTypeObject`s, cached enum singletons, and global engine pointers), so it +cannot be safely instantiated per-subinterpreter. Excluding subinterpreters from +the 1.0 promise leaves the later refactor (#220: heap types, multi-phase init, +per-interpreter module state) unconstrained. Heap-type conversion does not change +Python-visible behavior, so nothing else needs to be reserved for it. + +## Related + +- [Threading model](threading-model.md) — the `mcrfpy.lock()` off-main-thread + contract, which likewise freezes at 1.0. +- `tests/unit/api_surface_snapshot_test.py` — enforcement mechanism for the + public API surface. diff --git a/src/PyColor.h b/src/PyColor.h index 8701cdd..a521c77 100644 --- a/src/PyColor.h +++ b/src/PyColor.h @@ -60,21 +60,23 @@ namespace mcrfpydef { " a: Alpha component (0-255, default 255 = opaque)\n" "\n" "Note:\n" - " When accessing colors from UI elements (e.g., frame.fill_color),\n" - " you receive a COPY of the color. Modifying it doesn't affect the\n" - " original. To change a component:\n" + " Color is a VALUE TYPE (frozen 1.0 contract): properties that return a\n" + " Color (e.g. frame.fill_color) return a fresh COPY, so mutating a component\n" + " of the returned object is a silent no-op on the original. The two supported\n" + " idioms are read-modify-writeback and whole-value assignment:\n" "\n" " # This does NOT work:\n" " frame.fill_color.r = 255 # Modifies a temporary copy\n" "\n" - " # Do this instead:\n" + " # Read-modify-writeback:\n" " c = frame.fill_color\n" " c.r = 255\n" " frame.fill_color = c\n" "\n" - " # Or use Animation for sub-properties:\n" - " anim = mcrfpy.Animation('fill_color.r', 255, 0.5, 'linear')\n" - " anim.start(frame)\n" + " # Whole-value assignment:\n" + " frame.fill_color = mcrfpy.Color(255, 0, 0)\n" + "\n" + " See also: API stability policy (docs/api-stability.md)\n" ), .tp_richcompare = PyColor::richcompare, .tp_methods = PyColor::methods, diff --git a/src/PyVector.h b/src/PyVector.h index c61bfc0..51e3adb 100644 --- a/src/PyVector.h +++ b/src/PyVector.h @@ -84,7 +84,14 @@ namespace mcrfpydef { "Properties:\n" " x (float): X component.\n" " y (float): Y component.\n" - " int (tuple[int, int], read-only): Integer floor of (x, y).\n" + " int (tuple[int, int], read-only): Integer floor of (x, y).\n\n" + "Note:\n" + " Vector is a VALUE TYPE (frozen 1.0 contract): properties that return a\n" + " Vector return a fresh COPY, so mutating a component of the returned object\n" + " is a silent no-op on the original. Use read-modify-writeback\n" + " (v = obj.pos; v.x = 5; obj.pos = v) or whole-value assignment\n" + " (obj.pos = mcrfpy.Vector(5, 0)).\n" + " See also: API stability policy (docs/api-stability.md)\n" ), .tp_richcompare = PyVector::richcompare, .tp_methods = PyVector::methods,