From 225c6457e5ee27a855ed68708d275e1b0cae434d Mon Sep 17 00:00:00 2001 From: Moshu <1709219+DatMoshu@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:40:37 -0500 Subject: [PATCH 1/2] Harden local servers and prepare repo for public promotion - Fit Lab server: Host/Origin loopback guard (same rules as Content Studio) on GET/HEAD/POST, no directory listings under /data/ and /builds/, manifest and build-state errors returned as JSON, UTF-8 reads. - builds.py: clear ValueError when an item's part is missing from the mapping. - Client staging (client_import.stage, equipment.stage_equipment): check required client files before creating output; remove a partially written output folder on failure so retries work. - rebuild.py: remove staging/ when a patch merge fails; the Fit Lab render index ignores patch-only jobs so they never show as the newest render. - UTF-8 encoding for JSON read_text/write_text in fit-lab and uo-content; files closed with `with` in tools/vd. - Rename franchise-named outfit-lab demos to generic overalls and sci-fi plate armor (scripts, tests, launchers, docs, workspace folders). - docs/handoff.md: drop commercial pack names; add ROADMAP.md, CODE_OF_CONDUCT, SECURITY, issue templates (bug, feature, parity gap) and README badges and help-wanted section; ignore /.playwright-mcp/. - Tests for the Fit Lab host guard, directory listing, staging cleanup, failed rebuild cleanup and patch-job exclusion. Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 64 +++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 35 +++++ .github/ISSUE_TEMPLATE/parity_gap.yml | 76 ++++++++++ .gitignore | 3 + CODE_OF_CONDUCT.md | 135 ++++++++++++++++++ README.md | 19 ++- ROADMAP.md | 53 +++++++ SECURITY.md | 34 +++++ docs/handoff.md | 15 +- games/ultima-online/outfit-lab/ITEMS.md | 2 +- games/ultima-online/outfit-lab/README.md | 14 +- .../{build_mario.py => build_overalls.py} | 12 +- ...{build_spartan.py => build_plate_armor.py} | 12 +- .../outfit-lab/build_tracksuit.py | 4 +- ...eview_spartan.py => review_plate_armor.py} | 4 +- .../{test_mario.py => test_overalls.py} | 4 +- .../{test_spartan.py => test_plate_armor.py} | 4 +- launchers/editor/mario-lab.bat | 4 - launchers/editor/overalls-lab.bat | 9 ++ launchers/editor/plate-armor-lab.bat | 9 ++ launchers/editor/spartan-lab.bat | 9 -- tests/unit/test_client_staging.py | 91 ++++++++++++ tests/unit/test_fit_lab_renders.py | 6 + tests/unit/test_fit_lab_server.py | 68 +++++++++ tools/fit-lab/adjustments.py | 2 +- tools/fit-lab/builds.py | 12 +- tools/fit-lab/export_blender.py | 6 +- tools/fit-lab/reference.py | 2 +- tools/fit-lab/renders.py | 3 +- tools/fit-lab/run.py | 79 +++++++--- tools/uo-content/client_import.py | 51 ++++--- tools/uo-content/equipment.py | 57 ++++---- tools/uo-content/pipeline.py | 6 +- tools/uo-content/rebuild.py | 19 +-- tools/vd/mul2vd.py | 6 +- tools/vd/vdtool.py | 9 +- 37 files changed, 802 insertions(+), 144 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/parity_gap.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 ROADMAP.md create mode 100644 SECURITY.md rename games/ultima-online/outfit-lab/{build_mario.py => build_overalls.py} (80%) rename games/ultima-online/outfit-lab/{build_spartan.py => build_plate_armor.py} (81%) rename games/ultima-online/outfit-lab/{review_spartan.py => review_plate_armor.py} (94%) rename games/ultima-online/outfit-lab/{test_mario.py => test_overalls.py} (89%) rename games/ultima-online/outfit-lab/{test_spartan.py => test_plate_armor.py} (88%) delete mode 100644 launchers/editor/mario-lab.bat create mode 100644 launchers/editor/overalls-lab.bat create mode 100644 launchers/editor/plate-armor-lab.bat delete mode 100644 launchers/editor/spartan-lab.bat create mode 100644 tests/unit/test_client_staging.py create mode 100644 tests/unit/test_fit_lab_server.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..85bbd07 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug report +description: Something in SpriteMotion crashes, errors or behaves differently than documented. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. Please do not attach UO client files, extracted sprites, renders of original game art + or commercial asset-pack content. Screenshots of SpriteMotion's own UI and output are fine. + Security problems go through private vulnerability reporting instead (see SECURITY.md). + - type: dropdown + id: tool + attributes: + label: Tool + options: + - Content Studio + - Fit Lab + - Blender build pipeline + - Client staging tools + - Outfit lab + - Sprite Pose Editor + - Other / not sure + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you did, what you expected, and what happened instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Run launchers\editor\fit-lab.bat cc0-starter + 2. ... + validations: + required: true + - type: textarea + id: logs + attributes: + label: Error output or logs + description: Paste console output. Replace personal folder names with placeholders. + render: text + - type: input + id: commit + attributes: + label: Commit or branch + placeholder: main @ abc1234 + - type: input + id: environment + attributes: + label: Environment + description: OS, Python version, Blender version, browser. + placeholder: Windows 11, Python 3.12, Blender 4.2, Chrome + - type: checkboxes + id: content + attributes: + label: Content rules + options: + - label: I did not attach original client art, client files or commercial asset-pack content. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b0f56e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and ideas (Discussions) + url: https://github.com/DatMoshu/SpriteMotion/discussions + about: Ask setup questions, share results and discuss ideas before opening an issue. + - name: Wiki + url: https://github.com/DatMoshu/SpriteMotion/wiki + about: Setup walkthroughs and guides. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..ca5ba15 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest a new tool capability, workflow or documentation improvement. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + For open-ended ideas or questions, the Discussions board may be a better first stop. + Check [ROADMAP.md](https://github.com/DatMoshu/SpriteMotion/blob/main/ROADMAP.md) to see whether it is already planned. + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How should it work? Mention the tool (Content Studio, Fit Lab, build pipeline, ...) it affects. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: help + attributes: + label: Can you help build it? + options: + - "Yes, I can open a pull request" + - "I can test or review" + - "No" diff --git a/.github/ISSUE_TEMPLATE/parity_gap.yml b/.github/ISSUE_TEMPLATE/parity_gap.yml new file mode 100644 index 0000000..42eb17b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/parity_gap.yml @@ -0,0 +1,76 @@ +name: Parity gap +description: A rendered item does not match how original UO equipment looks or moves in a specific pose. +labels: [parity] +body: + - type: markdown + attributes: + value: | + Parity gaps drive the [roadmap](https://github.com/DatMoshu/SpriteMotion/blob/main/ROADMAP.md). + **Do not attach original client art** (extracted frames, sprite sheets, screenshots of the original game's + artwork) or commercial asset-pack content. Attach SpriteMotion output only and describe the original in words. + - type: input + id: action + attributes: + label: Action id + description: UO people animation action, 0-34. + placeholder: "9" + validations: + required: true + - type: dropdown + id: direction + attributes: + label: Direction + description: Stored directions are 0-4; 5-7 are mirrored views. + options: + - "0" + - "1" + - "2" + - "3" + - "4" + - "5 (mirrored)" + - "6 (mirrored)" + - "7 (mirrored)" + - "All / several" + validations: + required: true + - type: input + id: slot + attributes: + label: Slot + description: Equipment slot or UO layer. + placeholder: chest / helm / cloak / weapon ... + validations: + required: true + - type: input + id: item + attributes: + label: Item + description: Item id or name (CC0 starter item, your own model, or a generic description of a private pack item). + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected + description: How original equipment looks or behaves in this pose, in words. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual + description: What SpriteMotion produced (lab preview or Blender render, which build/job). + validations: + required: true + - type: textarea + id: screenshot + attributes: + label: Screenshot + description: Drop a screenshot of SpriteMotion output here. Never original client art. + - type: checkboxes + id: content + attributes: + label: Content rules + options: + - label: My attachments show only SpriteMotion output, not original client art or commercial pack content. + required: true diff --git a/.gitignore b/.gitignore index f653dd5..f5eafd7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ __pycache__/ /tests/output/ /tools/blender-runtime/ launchers/_shared/config.local.sh + +# Browser automation scratch +/.playwright-mcp/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4891766 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,135 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement privately, either +by opening a private report through the repository's +[Security tab](https://github.com/DatMoshu/SpriteMotion/security/advisories/new) +(GitHub private advisory, visible only to maintainers) or by contacting the +maintainer [@DatMoshu](https://github.com/DatMoshu) on GitHub. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/README.md b/README.md index 609f0eb..869918e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # SpriteMotion +[![CI](https://github.com/DatMoshu/SpriteMotion/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DatMoshu/SpriteMotion/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + + + **Create, fit and render equipment for Ultima Online's classic 2D characters.** SpriteMotion is a local content-authoring toolkit. Bring a 3D item, artwork or a @@ -117,11 +122,23 @@ To explore the original reconstruction workflow without game assets, use the [reconstruction workflow](docs/reconstruction-workflow.md) and [annotation format](docs/annotation-format.md) remain documented separately. +## Help wanted: toward UO parity + +The tools work; proving that every item looks right in every animation, facing and client is the open part. +[ROADMAP.md](ROADMAP.md) lists the render acceptance gates, what is already done and where help is wanted: +acceptance matrices across all 35 actions, rig targets for twist bones and cloth chains, complete-outfit reviews +and in-game client tests. Setup walkthroughs live in the [wiki](https://github.com/DatMoshu/SpriteMotion/wiki); +questions go to [Discussions](https://github.com/DatMoshu/SpriteMotion/discussions). Report a pose that does not +match original equipment with the **Parity gap** issue template, and read [CONTRIBUTING.md](CONTRIBUTING.md) +before opening a pull request. + ## Contributing Use a branch or fork and submit a pull request. Protected `main` requires the `guard` and `build` checks and maintainer code-owner approval; administrators -retain a bypass. See [CONTRIBUTING.md](CONTRIBUTING.md) for review and content rules. +retain a bypass. See [CONTRIBUTING.md](CONTRIBUTING.md) for review and content rules, +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community standards and [SECURITY.md](SECURITY.md) for +reporting vulnerabilities. ```powershell python -m pytest -q diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..bde9159 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,53 @@ +# Roadmap: toward UO parity + +SpriteMotion's goal is equipment that looks and moves like original Ultima Online art: every action, every facing, +correct layering, and a working import into a classic client. This page is the public, contributor-facing view of +where that stands. The detailed acceptance plan is [docs/render-release-roadmap.md](docs/render-release-roadmap.md); +the maintainer's running notes are in [docs/handoff.md](docs/handoff.md). + +Status legend: **done** (verified locally with evidence), **partial** (works on targeted cases, not full coverage), +**open** (not started or not yet proven). Help is welcome on anything marked partial or open. + +## What already works + +- **Fit Lab editing**: undo/redo with a 100-step history, autosave, browser crash recovery and three recoverable disk + saves. See [tools/fit-lab/README.md](tools/fit-lab/README.md). +- **Scoped corrections**: pack, slot, named-group and item corrections, optionally per action and/or direction. + Mirrored facings share their stored direction. The lab and Blender builds resolve them with the same rules. +- **Body masking**: torso-aware back-attachment masking, separate clothing masking and per-part body hiding under + clothes. See [docs/body-occlusion.md](docs/body-occlusion.md). +- **Builds from the lab**: build an item, then rebuild only its changed blocks into a new validated revision. + Untouched VD blocks stay byte-identical; mixed-version rebuilds are refused. +- **Current model**: the October 2026 UO_Model3D update (112-bone rig, native 256x256 canvas, anchor (128,192)). +- **CC0 starter equipment** for all 25 UO layer routes: [examples/cc0-starter](examples/cc0-starter/README.md). + +## Render acceptance gates + +| Gate | Status | What is left | Where help is wanted | +|---|---|---|---| +| 1. Final renders are authoritative | partial | Side-by-side cases showing the Blender renderer and the live lab agree on worn-part rules, outline correction, torso mask, push-out and mounted holdout. | Reproducible front/back/side comparisons; labelling preview-only differences. | +| 2. Every animation, every slot | open | At least three representative items per slot across all 35 actions, five stored directions plus three mirrored views, including large weapons, shields, cloaks, skirts, hair, falls and mounted poses. | Running and reviewing the acceptance matrix; filing [parity gaps](https://github.com/DatMoshu/SpriteMotion/issues/new?template=parity_gap.yml). | +| 3. Fix exceptions locally | partial | Action/direction/item/group corrections exist; twist, finger, shield and cloak/skirt chain targets (`proposed_target` in mappings) are not yet used by `pack_fit.py`. | Rig targets for twist bones, fingers and cloth chains; A/B evidence on failing poses. | +| 4. A complete outfit | open | Combined outfit layers with body hiding, straps, back items, weapons, paired pieces and garment intersections across the full action set. | Building and reviewing one full outfit; reporting layer-order or double-hiding issues. | +| 5. Client compatibility | open | Full VD output staged into a copy of a classic client, animation/static IDs and body/equipment conversions handled, then equipped in-game. | In-game tests on your own client/server; documenting Body.def / Bodyconv.def / Equipconv.def routing. | + +## Known open items + +- Not yet verified: a full 35-action lab build, a rebuild whose base slot fit changed, mounted actions, cloaks + (torso mask), and weapons on the new weapon bones. +- The lab's poke-through metric does not model the renderer's holdout margin or push-out; compare numbers, don't + read them as absolutes. +- Untested outfit-lab options: `merge_items.py`, `make_gump_cloak.py`, `build_item.py --planar/--cut`, + `atlas_to_vd.py --body/--outline`. +- Release path: reproducible clean-checkout setup, a procedural Blender regression scene for CI, then an alpha + release with a tested compatibility matrix. See the + [public-release path](docs/render-release-roadmap.md#public-release-path). + +## How to help + +- Read [CONTRIBUTING.md](CONTRIBUTING.md) first: never commit game data, extracted frames, commercial pack files or + machine paths. +- Use the [wiki](https://github.com/DatMoshu/SpriteMotion/wiki) for setup walkthroughs and the + [Discussions](https://github.com/DatMoshu/SpriteMotion/discussions) board for questions and ideas. +- Report a mismatch with original art using the **Parity gap** issue template: action id, direction, slot, item, + expected versus actual, and a screenshot of SpriteMotion output only (never original client art). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..907ab5f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security policy + +## What SpriteMotion runs + +SpriteMotion is a local authoring toolkit. Its web tools are small Python HTTP servers meant for one user on one +machine: + +| Tool | Default address | +|---|---| +| Content Studio (`tools/uo-content/studio.py`) | `http://127.0.0.1:8772` | +| Fit Lab (`tools/fit-lab/run.py serve`) | `http://127.0.0.1:8774` | + +Both bind to the loopback interface only and reject requests whose `Host` or `Origin` header is not +`127.0.0.1:` or `localhost:`, which blocks DNS-rebinding and cross-site requests from web pages. +State-changing requests accept JSON only. They are not designed to be exposed to a network: do not bind them to +`0.0.0.0`, port-forward them or put them behind a public reverse proxy. + +The tools read and write files under the repository's ignored `workspace/` folder, the local asset-pack sidecar +(`SPRITEMOTION_SIDECAR`) and folders you choose. Client staging writes to a new output folder and never modifies the +source client installation. + +## Supported versions + +Only the current `main` branch is supported. There are no released versions yet. + +## Reporting a vulnerability + +Please report vulnerabilities privately through GitHub's +[private vulnerability reporting](https://github.com/DatMoshu/SpriteMotion/security/advisories/new) +(repository **Security** tab, **Report a vulnerability**). Do not open a public issue for a security problem. + +Include the affected tool and commit, steps to reproduce and the impact you observed. Do not attach game client +files, extracted art or commercial asset-pack content. The maintainer will acknowledge the report in the advisory, +land the fix on `main` and credit you there unless you prefer otherwise. diff --git a/docs/handoff.md b/docs/handoff.md index 45cf888..bbf172f 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -54,13 +54,14 @@ order now matches Blender (XYZ), so saved multi-axis rotations preview different Verified 2026-10-02 against a scratch copy of the adjustments: item+pose correction, Ctrl+Z / Ctrl+Shift+Z (undo removes it from disk, redo restores it), mirrored direction 5 showing direction 3's correction and 4 not, a named -group from checked items with a per-action group correction. Lab build of the Elven back item, action 9: the scene +group from checked items with a per-action group correction. Lab build of a back item, action 9: the scene report shows +5 cm only in direction 3 and the group's +2 cm in all five; item-only frames keep just the parts outside the body except in the back view. Changing only the direction-3 correction and rebuilding re-rendered block (9,3) alone; the other four VD blocks were byte-identical and the revision validated. Not verified: a full 35-action lab build or a rebuild whose base slot fit changed. Each rebuild leaves its patch job -in `jobs/` (listed as a partial job by the studio). Studio pack +in `jobs/` (listed as a partial job by the studio); the fit lab's render index ignores patch-only jobs (`blocks` in +`job.json`), and a failed merge removes its `staging/` folder. Studio pack jobs started outside the lab need `fit_item` when the saved adjustments hold non-zero item offsets or non-pack corrections. @@ -102,13 +103,13 @@ native 256x256 canvas with anchor (128,192) and cuts items along the original bo well as `OCCLUDER_TRIS` per block, because hidden body faces change the triangle count. Body masking now includes the clavicles in the torso set. The fit lab was re-exported from the new model (reference, body, 72 items). -Verified 2026-10-02 on the new model: the Elven back item (action 9, scoped test corrections) built and validated with -the same canvas/anchor and per-block fits as on v13, frames within two pixels of the v13 build; the Elven torso +Verified 2026-10-02 on the new model: a back item (action 9, scoped test corrections) built and validated with +the same canvas/anchor and per-block fits as on v13, frames within two pixels of the v13 build; a torso item (actions 0/4, saved fits, hide-body on) hid 655 faces per block and validated, frames within ±2% pixels of a v13 build of the same job (shading differs: new lighting); a non-pack template chest (actions 4/9) built and validated. Not verified: full 35-action builds, mounted actions, cloaks (`TORSO_TRIS`), weapons on the new weapon bones. -Rollback: `pipeline.py setup --source workspace/uo-model-review/main`, and restore the lab export from -`workspace/uo-model-review/fitlab-synty-sidekick-v13/`. +Rollback: `pipeline.py setup --source workspace/uo-model-review/main`, and restore the lab export from the +local v13 fit-lab export backup kept under `workspace/uo-model-review/`. ## Verified in the takeover check @@ -145,7 +146,7 @@ Rollback: `pipeline.py setup --source workspace/uo-model-review/main`, and resto ## Housekeeping -- Local branch `backup/pre-sidekick-removal` holds the pre-rewrite history; delete it once the rewrite is accepted. +- A local backup branch holds the pre-rewrite history; delete it once the rewrite is accepted. Never push it. - Public repository: [DatMoshu/SpriteMotion](https://github.com/DatMoshu/SpriteMotion), configured as `origin`. Licensed-pack material lives only in the local sidecar repo (`SPRITEMOTION_SIDECAR`), which must never be pushed. - Publish only the reviewed `main` branch, never all branches or a mirror. diff --git a/games/ultima-online/outfit-lab/ITEMS.md b/games/ultima-online/outfit-lab/ITEMS.md index 5b2c2a5..e3aff9b 100644 --- a/games/ultima-online/outfit-lab/ITEMS.md +++ b/games/ultima-online/outfit-lab/ITEMS.md @@ -55,7 +55,7 @@ Examples measured on one shard's client. Check yours, because shards and client | Clothing, armour, robes: the shape stays, the material changes | texture transfer, `fit_texture` | `build_item.py` (one item) or `build.py` | | Flat item: shield, banner | whole picture laid on the item, `fit_planar` | `build_item.py --planar` | | Slender hand-held weapon: sword, staff, spear (long straight axis) | axis fit, `fit_lightsaber` | `build.py --config` with `axisFit` | -| Helmet with different views | one picture per direction (`fit_helmet`) | see `build_spartan.py` | +| Helmet with different views | one picture per direction (`fit_helmet`) | see `build_plate_armor.py` | | Shorter or ragged hanging item (cloak) | shape step, `shorten_frayed` | `build_item.py --cut/--fray` | Crossbows and bows have irregular shapes. The axis fit is untested on them. diff --git a/games/ultima-online/outfit-lab/README.md b/games/ultima-online/outfit-lab/README.md index b657cbc..acb5b15 100644 --- a/games/ultima-online/outfit-lab/README.md +++ b/games/ultima-online/outfit-lab/README.md @@ -89,10 +89,10 @@ The builder expects `design.png` (2x2 cells: jacket, pants, chain, shoes) and against fresh decoding; `review_tracksuit.py` checks rear-chain suppression and generates eight-direction walk and attack contact sheets. -## Spartan armor and directional helmet +## Sci-fi plate armor and directional helmet -`build_spartan.py` creates a separate Master Chief-style armor preview under -`workspace/ultima-online/spartan-lab/`. Six independent armor controls cover +`build_plate_armor.py` creates a separate sci-fi plate armor preview under +`workspace/ultima-online/plate-armor-lab/`. Six independent armor controls cover chest, arms, gloves, legs, boots and helmet, plus the existing energy sword. Original plate animations 527/528/530/529, boots 477 and helmet 563 supply registration and motion. Clothing uses material transfer, while the helmet @@ -104,11 +104,11 @@ Inputs are `design.png` (3x2: chest/arms/gloves, legs/boots/back chest), `energy-sword.png` (grip left). The original demos remain separate. ```powershell -python games/ultima-online/outfit-lab/build_spartan.py -python games/ultima-online/outfit-lab/review_spartan.py -python -m http.server 8769 --bind 127.0.0.1 --directory workspace/ultima-online/spartan-lab +python games/ultima-online/outfit-lab/build_plate_armor.py +python games/ultima-online/outfit-lab/review_plate_armor.py +python -m http.server 8769 --bind 127.0.0.1 --directory workspace/ultima-online/plate-armor-lab ``` -`launchers/editor/spartan-lab.bat` opens the offline preview. Evidence is saved +`launchers/editor/plate-armor-lab.bat` opens the offline preview. Evidence is saved alongside the generated output. The reviewer checks all stored rear-facing helmet frames for gold visor pixels and generates walk, idle and attack contact sheets. diff --git a/games/ultima-online/outfit-lab/build_mario.py b/games/ultima-online/outfit-lab/build_overalls.py similarity index 80% rename from games/ultima-online/outfit-lab/build_mario.py rename to games/ultima-online/outfit-lab/build_overalls.py index 1aefcf4..ec16cd6 100644 --- a/games/ultima-online/outfit-lab/build_mario.py +++ b/games/ultima-online/outfit-lab/build_overalls.py @@ -1,4 +1,4 @@ -"""Mario-inspired clothing preview. Cap unavailable: generator rejected its render.""" +"""Plumber-style overalls clothing preview. Cap unavailable: generator rejected its render.""" import json import os import hashlib @@ -7,9 +7,9 @@ from PIL import Image from uo import client_source from build import REPO,HERE,UOReader,canvas,fit_texture,occlude -from build_spartan import crop_asset +from build_plate_armor import crop_asset -OUT=REPO/'workspace/ultima-online/mario-lab' +OUT=REPO/'workspace/ultima-online/overalls-lab' MASKS=REPO/'workspace/ultima-online/region-audit/all-actions-region-pass/frames' SOURCE=os.environ.get('SPRITEMOTION_UO_SOURCE') PARTS=[('torso','Overalls bib and shirt',0x1517),('arms','Red sleeves',0x13CD), @@ -40,8 +40,8 @@ def main(): # Leather sleeves are the full-arm shape reference used by the mask pipeline. next(i for i in items if i['key']=='arms')['animId']=544 items.append(dict(key='mustache',displayName='Mustache',graphic=0,animId=0,label='Custom face overlay; no original animation')) - m=dict(title='Mario Outfit',body=400,canvas=256,origin=[128,192],items=items,actions=[],drawOrder=['legs','shoes','torso','arms','gloves','mustache'], - limitations=['Cap is missing: both image-generation attempts were rejected. This is an incomplete Mario-inspired outfit.', + m=dict(title='Overalls Outfit',body=400,canvas=256,origin=[128,192],items=items,actions=[],drawOrder=['legs','shoes','torso','arms','gloves','mustache'], + limitations=['Cap is missing: both image-generation attempts were rejected. This is an incomplete plumber-style overalls outfit.', 'Original body 400 animation with generated artwork fitted to native clothing silhouettes; not independent per-frame redraws.', 'Mustache is a custom overlay clipped to estimated visible head regions and omitted on rear views.', 'Extreme poses, true depth and garment deformation remain approximate; mounted actions have no mount.']) @@ -77,7 +77,7 @@ def main(): m['report']=report (OUT/'manifest.json').write_text(json.dumps(m,indent=2));(OUT/'data.js').write_text('window.OUTFIT='+json.dumps(m)+';') for name in ['index.html','viewer.js','style.css']:shutil.copyfile(HERE/name,OUT/name) - p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Mario Outfit').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red shirt, blue overalls, white gloves, brown shoes and mustache. Cap unavailable: its render was rejected.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Mustache is a custom layer with no original UO counterpart.').replace('','');p.write_text(s,encoding='utf-8') + p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Overalls Outfit').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red shirt, blue overalls, white gloves, brown shoes and mustache. Cap unavailable: its render was rejected.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Mustache is a custom layer with no original UO counterpart.').replace('','');p.write_text(s,encoding='utf-8') print(json.dumps(report)) if __name__=='__main__':main() diff --git a/games/ultima-online/outfit-lab/build_spartan.py b/games/ultima-online/outfit-lab/build_plate_armor.py similarity index 81% rename from games/ultima-online/outfit-lab/build_spartan.py rename to games/ultima-online/outfit-lab/build_plate_armor.py index 60db0ad..9e9368b 100644 --- a/games/ultima-online/outfit-lab/build_spartan.py +++ b/games/ultima-online/outfit-lab/build_plate_armor.py @@ -1,4 +1,4 @@ -"""Master Chief-style armor with directional helmet art on original UO animation.""" +"""Sci-fi plate armor with directional helmet art on original UO animation.""" import json import os import hashlib @@ -8,13 +8,13 @@ from uo import client_source from build import REPO, HERE, UOReader, canvas, fit_texture, fit_lightsaber, occlude -OUT=REPO/'workspace/ultima-online/spartan-lab' +OUT=REPO/'workspace/ultima-online/plate-armor-lab' MASKS=REPO/'workspace/ultima-online/region-audit/all-actions-region-pass/frames' SOURCE=os.environ.get('SPRITEMOTION_UO_SOURCE') PARTS=[('chest','Chest armor',0x1415),('arms','Shoulders and arms',0x1410), ('gloves','Gauntlets',0x1414),('legs','Leg armor',0x1411), - ('boots','Armored boots',0x170B),('helmet','Master Chief helmet',0x1412), - ('sword','Halo energy sword',0xF5E)] + ('boots','Armored boots',0x170B),('helmet','Sci-fi plate helmet',0x1412), + ('sword','Energy sword',0xF5E)] def crop_asset(image,threshold=100): a=np.array(image.convert('RGBA'));a[a[:,:,3]Robed','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Pane A uses original UO plate armor, helmet and broadsword animations.').replace('','');p.write_text(s,encoding='utf-8') + p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Sci-fi Plate Armor').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Sci-fi green plate armor and directional gold-visored helmet. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Pane A uses original UO plate armor, helmet and broadsword animations.').replace('','');p.write_text(s,encoding='utf-8') print(json.dumps(report)) if __name__=='__main__':main() diff --git a/games/ultima-online/outfit-lab/build_tracksuit.py b/games/ultima-online/outfit-lab/build_tracksuit.py index 9644752..e8253c6 100644 --- a/games/ultima-online/outfit-lab/build_tracksuit.py +++ b/games/ultima-online/outfit-lab/build_tracksuit.py @@ -54,7 +54,7 @@ def main(): weapon=Image.open(OUT/'energy-sword.png').convert('RGBA');box=weapon.getchannel('A').point(lambda a:255 if a>24 else 0).getbbox() designs['sword']=weapon.crop(box);designs['sword'].save(OUT/'designs/sword.png') r=UOReader(client_source(SOURCE)) - items=[dict(key=k,displayName=name,**r.item(g)) for k,name,g in [('shirt','Red tracksuit top',0x1517),('pants','Striped pants',0x1539),('shoes','Red sneakers',0x170F),('sword','Halo energy sword',0xF5E)]] + items=[dict(key=k,displayName=name,**r.item(g)) for k,name,g in [('shirt','Red tracksuit top',0x1517),('pants','Striped pants',0x1539),('shoes','Red sneakers',0x170F),('sword','Energy sword',0xF5E)]] items.insert(3,dict(key='chain',displayName='Gold chain',label='Custom necklace overlay (no original animation)',graphic=0,animId=0)) m=dict(title='Crimson Runner',body=400,canvas=256,origin=[128,192],items=items,actions=[],drawOrder=['pants','shoes','shirt','chain','sword'], limitations=['Original body 400 frames and native equipment alignment; experimental 2D fitting.', @@ -103,7 +103,7 @@ def main(): m['report']=report (OUT/'manifest.json').write_text(json.dumps(m,indent=2));(OUT/'data.js').write_text('window.OUTFIT='+json.dumps(m)+';') for name in ['index.html','viewer.js','style.css']:shutil.copyfile(HERE/name,OUT/name) - p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Crimson Runner').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red tracksuit, white arm and leg stripes, gold chain and Halo-style energy sword. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','The gold chain is a new torso overlay with no original UO animation. The original top combines a shirt and leather sleeves.').replace('','');p.write_text(s,encoding='utf-8') + p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Crimson Runner').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red tracksuit, white arm and leg stripes, gold chain and sci-fi energy sword. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','The gold chain is a new torso overlay with no original UO animation. The original top combines a shirt and leather sleeves.').replace('','');p.write_text(s,encoding='utf-8') print(json.dumps(report)) if __name__=='__main__':main() diff --git a/games/ultima-online/outfit-lab/review_spartan.py b/games/ultima-online/outfit-lab/review_plate_armor.py similarity index 94% rename from games/ultima-online/outfit-lab/review_spartan.py rename to games/ultima-online/outfit-lab/review_plate_armor.py index 3a5a2d1..abf027a 100644 --- a/games/ultima-online/outfit-lab/review_spartan.py +++ b/games/ultima-online/outfit-lab/review_plate_armor.py @@ -1,8 +1,8 @@ -"""Eight-direction evidence and rear-visor checks for the Spartan preview.""" +"""Eight-direction evidence and rear-visor checks for the sci-fi plate armor preview.""" import json import numpy as np from PIL import Image,ImageDraw -from build_spartan import OUT +from build_plate_armor import OUT def gold_pixels(im): a=np.array(im).astype(float);r,g,b,alpha=[a[:,:,i] for i in range(4)] diff --git a/games/ultima-online/outfit-lab/test_mario.py b/games/ultima-online/outfit-lab/test_overalls.py similarity index 89% rename from games/ultima-online/outfit-lab/test_mario.py rename to games/ultima-online/outfit-lab/test_overalls.py index ea4b576..8fe0257 100644 --- a/games/ultima-online/outfit-lab/test_mario.py +++ b/games/ultima-online/outfit-lab/test_overalls.py @@ -1,9 +1,9 @@ import unittest import numpy as np from PIL import Image -from build_mario import mustache +from build_overalls import mustache -class MarioTests(unittest.TestCase): +class OverallsTests(unittest.TestCase): def test_mustache_clips_to_head_and_hides_on_back(self): labels=np.zeros((256,256),np.uint8);labels[100:111,122:133]=1 design=Image.new('RGBA',(40,15),'black') diff --git a/games/ultima-online/outfit-lab/test_spartan.py b/games/ultima-online/outfit-lab/test_plate_armor.py similarity index 88% rename from games/ultima-online/outfit-lab/test_spartan.py rename to games/ultima-online/outfit-lab/test_plate_armor.py index 73b5ed8..684137c 100644 --- a/games/ultima-online/outfit-lab/test_spartan.py +++ b/games/ultima-online/outfit-lab/test_plate_armor.py @@ -1,9 +1,9 @@ import unittest import numpy as np from PIL import Image -from build_spartan import crop_asset,fit_helmet +from build_plate_armor import crop_asset,fit_helmet -class SpartanTests(unittest.TestCase): +class PlateArmorTests(unittest.TestCase): def test_empty_helmet_stays_empty(self): self.assertIsNone(fit_helmet(Image.new('RGBA',(256,256)),Image.new('RGBA',(20,20),'gold')).getbbox()) diff --git a/launchers/editor/mario-lab.bat b/launchers/editor/mario-lab.bat deleted file mode 100644 index cf13e42..0000000 --- a/launchers/editor/mario-lab.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -setlocal -cd /d "%~dp0\..\.." -start "" "workspace\ultima-online\mario-lab\index.html" diff --git a/launchers/editor/overalls-lab.bat b/launchers/editor/overalls-lab.bat new file mode 100644 index 0000000..cb34f42 --- /dev/null +++ b/launchers/editor/overalls-lab.bat @@ -0,0 +1,9 @@ +@echo off +@rem Opens the offline overalls outfit preview built by games\ultima-online\outfit-lab\build_overalls.py. +call "%~dp0..\_shared\common.bat" || exit /b 1 +if not exist "workspace\ultima-online\overalls-lab\index.html" ( + echo Build with python games\ultima-online\outfit-lab\build_overalls.py first. + pause + exit /b 1 +) +start "" "workspace\ultima-online\overalls-lab\index.html" diff --git a/launchers/editor/plate-armor-lab.bat b/launchers/editor/plate-armor-lab.bat new file mode 100644 index 0000000..57921d7 --- /dev/null +++ b/launchers/editor/plate-armor-lab.bat @@ -0,0 +1,9 @@ +@echo off +@rem Opens the offline sci-fi plate armor preview built by games\ultima-online\outfit-lab\build_plate_armor.py. +call "%~dp0..\_shared\common.bat" || exit /b 1 +if not exist "workspace\ultima-online\plate-armor-lab\index.html" ( + echo Build with python games\ultima-online\outfit-lab\build_plate_armor.py first. + pause + exit /b 1 +) +start "" "workspace\ultima-online\plate-armor-lab\index.html" diff --git a/launchers/editor/spartan-lab.bat b/launchers/editor/spartan-lab.bat deleted file mode 100644 index 45bfa7f..0000000 --- a/launchers/editor/spartan-lab.bat +++ /dev/null @@ -1,9 +0,0 @@ -@echo off -setlocal -cd /d "%~dp0\..\.." -if not exist "workspace\ultima-online\spartan-lab\index.html" ( - echo Build with python games\ultima-online\outfit-lab\build_spartan.py first. - pause - exit /b 1 -) -start "" "workspace\ultima-online\spartan-lab\index.html" diff --git a/tests/unit/test_client_staging.py b/tests/unit/test_client_staging.py new file mode 100644 index 0000000..d9b72c1 --- /dev/null +++ b/tests/unit/test_client_staging.py @@ -0,0 +1,91 @@ +"""Client staging fails before creating output when files are missing, and removes partial output on failure. + +Fixtures are synthetic byte buffers, never client data. +""" +import json +from pathlib import Path +import sys + +import pytest +from PIL import Image + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'tools/uo-content')) +sys.path.insert(0, str(ROOT / 'tools/fit-lab')) +import client_import +import equipment +import pipeline +import rebuild + +TILEDATA_ITEMS = 0x1420 // 32 + 1 # enough item groups for the chest template graphic + + +def client(tmp_path, skip=()): + folder = tmp_path / 'client'; folder.mkdir(parents=True) + files = {'anim.mul': b'', 'anim.idx': b'', 'art.mul': b'', 'artidx.mul': b'', + 'tiledata.mul': bytes(512 * (4 + 32 * 26) + TILEDATA_ITEMS * (4 + 32 * 37))} + for name, data in files.items(): + if name not in skip: (folder / name).write_bytes(data) + return folder + + +def job(tmp_path): + folder = tmp_path / 'job'; folder.mkdir() + (folder / 'job.json').write_text(json.dumps({'name': 'Test chest', 'part': 'chest'}), encoding='utf-8') + (folder / 'validation.json').write_text(json.dumps({'clipped_frames': 0}), encoding='utf-8') + image = Image.new('RGBA', (4, 4)); image.putpixel((1, 1), (200, 10, 10, 255)) + image.save(folder / 'inventory.png') + return folder + + +@pytest.mark.parametrize('missing', ['art.mul', 'artidx.mul', 'tiledata.mul', 'anim.mul']) +def test_equipment_missing_client_file_fails_before_output(tmp_path, missing): + folder = job(tmp_path) + with pytest.raises(ValueError, match=missing): + equipment.stage_equipment(folder, client(tmp_path, skip=(missing,)), 400, 0x1416) + assert not (folder / 'staged-client').exists() + + +def test_anim_stage_missing_client_file_fails_before_output(tmp_path): + out = tmp_path / 'out' + with pytest.raises(ValueError, match='anim.idx'): + client_import.stage(tmp_path / 'item.vd', client(tmp_path, skip=('anim.idx',)), 400, out) + assert not out.exists() + + +def test_equipment_failure_after_staging_removes_partial_output(tmp_path, monkeypatch): + folder = job(tmp_path) + + def fake_stage(vd, source, body, out): + Path(out).mkdir(); (Path(out) / 'anim.mul').write_bytes(b'partial') + return {'source_hashes': {}, 'staged_hashes': {}} + + def broken_source(*args): + raise OSError('disk full') + + monkeypatch.setattr(equipment, 'stage', fake_stage) + monkeypatch.setattr(equipment, 'server_source', broken_source) + with pytest.raises(OSError, match='disk full'): + equipment.stage_equipment(folder, client(tmp_path), 400, 0x1416) + assert not (folder / 'staged-client').exists() + # The retry is not blocked by a leftover folder. + monkeypatch.setattr(equipment, 'server_source', lambda *a: '// item') + assert equipment.stage_equipment(folder, client(tmp_path / 'again'), 400, 0x1416)['graphic'] == 0x1416 + + +def test_failed_rebuild_merge_removes_staging(tmp_path, monkeypatch): + home, source, patch = tmp_path / 'home', tmp_path / 'home/jobs/source', tmp_path / 'home/jobs/patch' + source.mkdir(parents=True); patch.mkdir() + (source / 'job.json').write_text(json.dumps({'backend_sha256': 'model', 'render_fingerprint': 'renderer'}), encoding='utf-8') + (source / 'item.vd').write_bytes(b'') + monkeypatch.setattr(pipeline, 'HOME', home) + monkeypatch.setattr(pipeline, 'sha', lambda path: 'model') + monkeypatch.setattr(pipeline, 'render_fingerprint', lambda: 'renderer') + monkeypatch.setattr(pipeline, 'create_job', lambda spec, asset=None: patch) + monkeypatch.setattr(pipeline, 'run_job', lambda job: None) + monkeypatch.setattr(client_import, 'vd_blocks', lambda path: {(9, 0): b'', (9, 3): b''}) + monkeypatch.setattr(rebuild, 'changed_blocks', lambda spec, document, blocks: [[9, 3]]) + with pytest.raises(OSError): # the source has no render/clothing/meta.json to merge into + rebuild.rebuild_job(source, {'parts': {}, 'items': {}}) + assert list((home / 'staging').iterdir()) == [] + assert sorted(p.name for p in (home / 'jobs').iterdir()) == ['patch', 'source'] diff --git a/tests/unit/test_fit_lab_renders.py b/tests/unit/test_fit_lab_renders.py index 3ea3a78..3237c1b 100644 --- a/tests/unit/test_fit_lab_renders.py +++ b/tests/unit/test_fit_lab_renders.py @@ -40,3 +40,9 @@ def test_progress_counts_frames_of_the_running_job(tmp_path): patch = job(tmp_path, 'patch', 'other', state='building', actions=(9,), blocks=[[9, 3]]) assert renders.RenderIndex(tmp_path).progress('other', started, {9: 7}) == {'done': 0, 'total': 7} assert renders.RenderIndex(tmp_path).progress('missing', started, {}) is None + + +def test_patch_only_rebuild_jobs_are_not_listed_as_renders(tmp_path): + full = job(tmp_path, 'full', 'cloak', actions=(9,)); os.utime(full / 'status.json', (1, 1)) + job(tmp_path, 'patch', 'cloak', actions=(9,), blocks=[[9, 3]]) # newer, but holds one block only + assert [r['job'] for r in renders.RenderIndex(tmp_path).renders('cloak')] == ['full'] diff --git a/tests/unit/test_fit_lab_server.py b/tests/unit/test_fit_lab_server.py new file mode 100644 index 0000000..f03597d --- /dev/null +++ b/tests/unit/test_fit_lab_server.py @@ -0,0 +1,68 @@ +"""Fit Lab HTTP server: loopback Host/Origin guard, no directory listings, JSON errors for a missing export.""" +import http.client +import http.server +import importlib.util +import json +from pathlib import Path +import sys +import threading + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'tools/fit-lab')) +spec = importlib.util.spec_from_file_location('fit_lab_run', ROOT / 'tools/fit-lab/run.py') +run = importlib.util.module_from_spec(spec); spec.loader.exec_module(run) + + +class NoBuilds: + def state(self): return {'state': 'idle'} + + +@pytest.fixture +def lab(tmp_path): + data = tmp_path / 'data'; (data / 'sub').mkdir(parents=True) + (data / 'sub/file.json').write_text('{}', encoding='utf-8') + store = run.AdjustmentStore(tmp_path / 'lab-adjustments.json') + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), run.make_handler('test-pack', data, store, NoBuilds())) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + yield server + server.shutdown(); server.server_close() + + +def request(server, method, path, host=None, origin=None, body=None): + conn = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=10) + headers = {'Host': host or f'127.0.0.1:{server.server_port}'} + if origin: headers['Origin'] = origin + if body is not None: headers['Content-Type'] = 'application/json' + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse(); payload = response.read(); conn.close() + return response.status, payload + + +def test_local_host_passes(lab): + status, body = request(lab, 'GET', '/api/service') + assert status == 200 and json.loads(body)['pack'] == 'test-pack' + assert request(lab, 'GET', '/api/service', host=f'localhost:{lab.server_port}')[0] == 200 + assert request(lab, 'GET', '/api/service', origin=f'http://127.0.0.1:{lab.server_port}')[0] == 200 + + +@pytest.mark.parametrize('method,path,body', [('GET', '/api/service', None), ('GET', '/', None), + ('GET', '/data/sub/file.json', None), + ('POST', '/api/adjustments', '{}')]) +def test_foreign_host_or_origin_is_refused(lab, method, path, body): + # DNS rebinding: the browser sends the attacker's host name to our loopback port. + assert request(lab, method, path, host=f'evil.example:{lab.server_port}', body=body)[0] == 403 + assert request(lab, method, path, host=f'127.0.0.1:{lab.server_port + 1}', body=body)[0] == 403 + assert request(lab, method, path, origin='http://evil.example', body=body)[0] == 403 + + +def test_directories_are_not_listed(lab): + assert request(lab, 'GET', '/data/sub/')[0] == 404 + assert request(lab, 'GET', '/builds/')[0] == 404 + assert request(lab, 'GET', '/data/sub/file.json')[0] == 200 + + +def test_missing_manifest_returns_json_error(lab): + status, body = request(lab, 'GET', '/api/mapping') + assert status == 404 and 'error' in json.loads(body) diff --git a/tools/fit-lab/adjustments.py b/tools/fit-lab/adjustments.py index 9343156..7ca59d4 100644 --- a/tools/fit-lab/adjustments.py +++ b/tools/fit-lab/adjustments.py @@ -128,7 +128,7 @@ def save(self, data, base_revision): # Back up even the initial empty state so the first save is reversible. stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') backups = self._backups() - if not backups or canonical(json.loads(backups[0].read_text())) != canonical(current['adjustments']): + if not backups or canonical(json.loads(backups[0].read_text(encoding='utf-8'))) != canonical(current['adjustments']): atomic_write(self.backup_dir / f'{stamp}-{uuid4().hex[:8]}.json', canonical(current['adjustments'])) for old in self._backups()[3:]: old.unlink() diff --git a/tools/fit-lab/builds.py b/tools/fit-lab/builds.py index 46c04b8..46ecccf 100644 --- a/tools/fit-lab/builds.py +++ b/tools/fit-lab/builds.py @@ -25,7 +25,7 @@ def __init__(self, data, store): def state(self): with self.lock: status = dict(self.status) if status['state']=='building': - manifest = json.loads((self.data/'manifest.json').read_text()) + manifest = json.loads((self.data/'manifest.json').read_text(encoding='utf-8')) frames = {a['id']:a['frames'] for a in manifest['actions']} progress = self.renders.progress(status['item'],status['started'],frames) if progress: status['progress'] = progress @@ -37,11 +37,11 @@ def start(self, request): if request['mode'] not in ('build','rebuild') or request['coverage'] not in ('preview','full','action'): raise ValueError('Invalid build mode.') if type(request['action']) is not int or not 0<=request['action']<=34: raise ValueError('Invalid action.') - catalog = json.loads((self.data/'lab-items.json').read_text()) + catalog = json.loads((self.data/'lab-items.json').read_text(encoding='utf-8')) item = next((i for i in catalog['items'] if i['id']==request['item']),None) if item is None: raise ValueError('Builds require a mapped source item; directory imports are preview-only.') document = self.store.state()['adjustments'] - last = json.loads(self.path.read_text()) if self.path.exists() else {} + last = json.loads(self.path.read_text(encoding='utf-8')) if self.path.exists() else {} parent = last.get(item['id']) if request['mode']=='rebuild' and not parent: raise ValueError('Build this item once before rebuilding changed blocks.') with self.lock: @@ -56,8 +56,10 @@ def run(self, request, catalog, item, document, last, parent): if not isinstance(parent,str) or not parent.isalnum(): raise ValueError('Invalid saved job ID.') job = rebuild_job(pipeline.HOME/'jobs'/parent,document) else: - mapping = json.loads(Path(catalog['mapping']).read_text()) - part = next(p for p in mapping['parts'] if p['code']==item['part']) + mapping = json.loads(Path(catalog['mapping']).read_text(encoding='utf-8')) + part = next((p for p in mapping['parts'] if p['code']==item['part']),None) + if part is None: + raise ValueError(f"Part {item['part']!r} of item {item['id']!r} is missing from mapping {catalog['mapping']}.") mode = 'full' if request['coverage']=='full' else 'preview' spec = {'name':item['id'], 'part':part['studio_part'], 'mode':mode, 'fit':'preserve', 'source_files':item['files'], 'palette':item.get('palette'), 'pack_mapping':catalog['mapping'], diff --git a/tools/fit-lab/export_blender.py b/tools/fit-lab/export_blender.py index 87ec5bc..bf829e6 100644 --- a/tools/fit-lab/export_blender.py +++ b/tools/fit-lab/export_blender.py @@ -44,7 +44,7 @@ acts = sorted((a for a in bpy.data.actions if 'uo_action' in a), key=lambda a: int(a['uo_action'])) scene = bpy.context.scene manifest_path = OUT / 'manifest.json' -manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {} +manifest = json.loads(manifest_path.read_text(encoding='utf-8')) if manifest_path.exists() else {} manifest.update({ 'model': MODEL.name, 'rig': 'uo-model3d-v13', 'fps': scene.render.fps, 'frame_step': STEP, 'camera': {**CAMERA, @@ -103,7 +103,7 @@ def export(path, objects, animations): if x.users == 0: block.remove(x) print(f"FITLAB item {n + 1}/{len(items_doc['items'])} {item['id']}", flush=True) manifest['items'] = sorted(known.values(), key=lambda i: (i.get('slot', ''), i['id'])) - manifest_path.write_text(json.dumps(manifest, indent=1)) + manifest_path.write_text(json.dumps(manifest, indent=1), encoding='utf-8') manifest['items'] = sorted(known.values(), key=lambda i: (i.get('slot', ''), i['id'])) -manifest_path.write_text(json.dumps(manifest, indent=1)) +manifest_path.write_text(json.dumps(manifest, indent=1), encoding='utf-8') print('FITLAB done', len(manifest['items']), 'items', flush=True) diff --git a/tools/fit-lab/reference.py b/tools/fit-lab/reference.py index 25d1f32..82db993 100644 --- a/tools/fit-lab/reference.py +++ b/tools/fit-lab/reference.py @@ -30,4 +30,4 @@ def chunk(kind, data): png = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', cols * width, rows * height, 8, 6, 0, 0, 0)) png += chunk(b'IDAT', zlib.compress(scanlines)) + chunk(b'IEND', b'') (directory / 'reference.png').write_bytes(png) - (directory / 'reference.json').write_text(json.dumps({'image': 'reference.png', 'tile': [width, height], 'tiles': tiles})) + (directory / 'reference.json').write_text(json.dumps({'image': 'reference.png', 'tile': [width, height], 'tiles': tiles}), encoding='utf-8') diff --git a/tools/fit-lab/renders.py b/tools/fit-lab/renders.py index 758bc6f..c745942 100644 --- a/tools/fit-lab/renders.py +++ b/tools/fit-lab/renders.py @@ -26,7 +26,8 @@ def scan(self): if not status or status.get('state') not in ('complete', 'failed'): continue # retry while running self.seen.add(entry.name) spec, review = read_json(Path(entry.path, 'job.json')), read_json(Path(entry.path, 'review/manifest.json')) - if status['state'] != 'complete' or not spec or not review or not spec.get('fit_item'): continue + # Patch jobs ('blocks') hold only changed blocks for a rebuild merge, never a whole render. + if status['state'] != 'complete' or not spec or not review or not spec.get('fit_item') or 'blocks' in spec: continue self.known[entry.name] = {'job': entry.name, 'item': spec['fit_item']['id'], 'actions': sorted({s['action'] for s in review['sequences']}), 'finished': Path(entry.path, 'status.json').stat().st_mtime} diff --git a/tools/fit-lab/run.py b/tools/fit-lab/run.py index 5d32f89..9f0de8f 100644 --- a/tools/fit-lab/run.py +++ b/tools/fit-lab/run.py @@ -50,12 +50,8 @@ def export(args): sys.exit(subprocess.call(cmd + (['--force'] if args.force else []))) -def serve(args): - d = data_dir(args.pack) - adjust = (d / 'lab-adjustments.json' if args.pack == 'cc0-starter' - else sidecar() / 'packs' / args.pack / 'lab-adjustments.json') - store = AdjustmentStore(adjust) - builds = Builds(d,store) +def make_handler(pack, d, store, builds): + """Request handler for one lab. Loopback only: Host/Origin are checked on every request (DNS rebinding).""" class Handler(http.server.SimpleHTTPRequestHandler): extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, '.mjs': 'text/javascript'} @@ -63,6 +59,13 @@ class Handler(http.server.SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Cache-Control', 'no-store') super().end_headers() + + def local(self): + expected = {f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}'} + if self.headers.get('Host') not in expected: return False + origin = self.headers.get('Origin') + return not origin or origin in {'http://' + h for h in expected} + def translate_path(self, path): path = unquote(urlsplit(path).path) root = d if path.startswith('/data/') else HERE / 'web' @@ -75,18 +78,26 @@ def translate_path(self, path): target = (root / relative).resolve() return str(target) if target.is_relative_to(root.resolve()) else str(root / '__not_found__') + def list_directory(self, path): + # Never list job, data or web folders; only named files are served. + self.send_error(404, 'Not found') + return None + + def manifest(self): + return json.loads((d / 'manifest.json').read_text(encoding='utf-8')) + def do_GET(self): + if not self.local(): return self.reply(403, b'{"error":"Local requests only."}') path = urlsplit(self.path).path - if path == '/api/service': return self.reply(200, json.dumps(describe(args.pack)).encode()) - if path == '/api/build': return self.reply(200,json.dumps(builds.state()).encode()) - if path == '/api/renders': - item = parse_qs(urlsplit(self.path).query).get('item', [''])[0] - return self.reply(200, json.dumps({'renders': builds.renders.renders(item)}).encode()) - if path == '/api/mapping': - manifest = json.loads((d / 'manifest.json').read_text()) - mapping = Path(manifest.get('mapping') or '') - return self.reply(200, mapping.read_bytes()) if mapping.is_file() else self.reply(404, b'{}') + if path == '/api/service': return self.reply(200, json.dumps(describe(pack)).encode()) try: + if path == '/api/build': return self.reply(200, json.dumps(builds.state()).encode()) + if path == '/api/renders': + item = parse_qs(urlsplit(self.path).query).get('item', [''])[0] + return self.reply(200, json.dumps({'renders': builds.renders.renders(item)}).encode()) + if path == '/api/mapping': + mapping = Path(self.manifest().get('mapping') or '') + return self.reply(200, mapping.read_bytes()) if mapping.is_file() else self.reply(404, b'{}') if path == '/api/state': return self.reply(200, json.dumps(store.state()).encode()) if path == '/api/adjustments': @@ -95,11 +106,16 @@ def do_GET(self): return self.reply(200, json.dumps(store.backup(path.removeprefix('/api/backups/'))).encode()) except FileNotFoundError as e: return self.reply(404, json.dumps({'error': str(e)}).encode()) - except (OSError, ValueError) as e: + except (OSError, ValueError, KeyError, TypeError, AttributeError) as e: return self.reply(500, json.dumps({'error': str(e)}).encode()) return super().do_GET() + def do_HEAD(self): + if not self.local(): return self.reply(403, b'{"error":"Local requests only."}') + return super().do_HEAD() + def do_POST(self): + if not self.local(): return self.reply(403, b'{"error":"Local requests only."}') path = urlsplit(self.path).path if path not in ('/api/adjustments', '/api/assets', '/api/build'): return self.reply(404, b'{}') # JSON-only requests prevent cross-origin forms from changing local fits. @@ -113,7 +129,7 @@ def do_POST(self): if path == '/api/assets': if not isinstance(data, dict) or set(data) != {'directory', 'slot', 'part'} or not all(isinstance(v, str) for v in data.values()): raise ValueError('Expected directory, slot and part strings') - manifest = json.loads((d / 'manifest.json').read_text()) + manifest = self.manifest() if not any(i['slot'] == data['slot'] and i['part'] == data['part'] for i in manifest['items']): raise ValueError('Select a known slot first') result = import_directory(data['directory'], d, data['slot'], data['part']) @@ -135,15 +151,32 @@ def reply(self, code, body): def log_message(self, *a): pass + return Handler + + +def serve(args): + d = data_dir(args.pack) + adjust = (d / 'lab-adjustments.json' if args.pack == 'cc0-starter' + else sidecar() / 'packs' / args.pack / 'lab-adjustments.json') + store = AdjustmentStore(adjust) + builds = Builds(d, store) + Handler = make_handler(args.pack, d, store, builds) + url = f'http://127.0.0.1:{args.port}/' print('Fit lab:', url, '| data', d, '| saves to', adjust, flush=True) if not args.no_browser: webbrowser.open(url) http.server.ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever() -ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) -sub = ap.add_subparsers(dest='cmd', required=True) -e = sub.add_parser('export'); e.add_argument('--pack', required=True); e.add_argument('--force', action='store_true'); e.set_defaults(fn=export) -s = sub.add_parser('serve'); s.add_argument('--pack', required=True); s.add_argument('--port', type=int, default=8774) -s.add_argument('--no-browser', action='store_true'); s.set_defaults(fn=serve) -args = ap.parse_args(); args.fn(args) + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest='cmd', required=True) + e = sub.add_parser('export'); e.add_argument('--pack', required=True); e.add_argument('--force', action='store_true'); e.set_defaults(fn=export) + s = sub.add_parser('serve'); s.add_argument('--pack', required=True); s.add_argument('--port', type=int, default=8774) + s.add_argument('--no-browser', action='store_true'); s.set_defaults(fn=serve) + args = ap.parse_args(); args.fn(args) + + +if __name__ == '__main__': + main() diff --git a/tools/uo-content/client_import.py b/tools/uo-content/client_import.py index d385538..25e0283 100644 --- a/tools/uo-content/client_import.py +++ b/tools/uo-content/client_import.py @@ -46,9 +46,17 @@ def file_sha(path): for chunk in iter(lambda:f.read(1024*1024),b''): h.update(chunk) return h.hexdigest() +ANIM_FILES=('anim.mul','anim.idx') + +def require_client_files(client, names): + """Fail before any output folder exists when the selected client lacks a file staging needs.""" + missing=[n for n in names if not (Path(client)/n).is_file()] + if missing: raise ValueError(f'Client folder is missing {", ".join(missing)}: choose a classic MUL client folder.') + def stage(vd, client, body, out): vd,client,out=Path(vd).resolve(),Path(client).resolve(),Path(out).resolve() if not 400<=body<=2047: raise ValueError('Choose a people animation ID from 400 to 2047.') + require_client_files(client,ANIM_FILES) blocks=vd_blocks(vd) if len(blocks)!=175: raise ValueError('Preview VD is incomplete. Build all 35 actions before importing.') for a in range(35): @@ -75,25 +83,30 @@ def stage(vd, client, body, out): except ValueError as e: if 'routed' in str(e): raise out.mkdir(parents=True) - shutil.copy2(client/'anim.mul',out/'anim.mul') - dst=bytearray(index) - while len(dst)<(first+175)*12: dst+=struct.pack('0,np.asarray(image)[...,3]>=128): raise ValueError('Inventory art round-trip failed.') out=job/'staged-client' - result=stage(job/'item.vd',client,body,out) - data[target:target+size]=data[source:source+size] - struct.pack_into(' Date: Fri, 9 Oct 2026 19:40:41 -0500 Subject: [PATCH 2/2] Guard the pose editor server, HEAD on Studio, overalls/plate-armor launchers, energy blade rename Shared loopback guard (common/local_guard.py) on the live pose editor (Host/Origin on GET/HEAD/POST, Origin and JSON required on POST, no listings, static files confined to its output folder) and on Content Studio, which also gets do_HEAD. SECURITY.md lists all three servers and the trusted-local-user path features. Launcher rem lines and .sh twins for overalls-lab and plate-armor-lab, LAUNCHER_EXEMPT emptied. ROADMAP poke-metric line reworded. Outfit-lab 'lightsaber' renamed to 'energy blade'. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +- SECURITY.md | 17 ++- common/local_guard.py | 20 +++ games/ultima-online/outfit-lab/ITEMS.md | 6 +- games/ultima-online/outfit-lab/README.md | 4 +- games/ultima-online/outfit-lab/build.py | 24 ++-- .../outfit-lab/build_overalls.py | 2 +- .../outfit-lab/build_plate_armor.py | 6 +- .../outfit-lab/build_tracksuit.py | 6 +- games/ultima-online/outfit-lab/index.html | 2 +- games/ultima-online/outfit-lab/make_gump.py | 8 +- .../ultima-online/outfit-lab/test_fitting.py | 10 +- .../region-masks/pose_editor_server.py | 22 +++- launchers/README.md | 3 +- launchers/editor/overalls-lab.bat | 5 +- launchers/editor/overalls-lab.sh | 8 ++ launchers/editor/plate-armor-lab.bat | 5 +- launchers/editor/plate-armor-lab.sh | 8 ++ tests/integration/test_repository.py | 3 +- tests/unit/test_local_servers.py | 118 ++++++++++++++++++ tools/uo-content/studio.py | 13 +- 21 files changed, 237 insertions(+), 57 deletions(-) create mode 100644 common/local_guard.py create mode 100755 launchers/editor/overalls-lab.sh create mode 100755 launchers/editor/plate-armor-lab.sh create mode 100644 tests/unit/test_local_servers.py diff --git a/ROADMAP.md b/ROADMAP.md index bde9159..87e52f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,8 +35,8 @@ Status legend: **done** (verified locally with evidence), **partial** (works on - Not yet verified: a full 35-action lab build, a rebuild whose base slot fit changed, mounted actions, cloaks (torso mask), and weapons on the new weapon bones. -- The lab's poke-through metric does not model the renderer's holdout margin or push-out; compare numbers, don't - read them as absolutes. +- The lab's poke-through metric models the renderer's 1 cm holdout margin, its push-out (reported as an upper + bound) and the occlusion modes. It is still a measure on the lab's 3D body, so compare numbers between fits. - Untested outfit-lab options: `merge_items.py`, `make_gump_cloak.py`, `build_item.py --planar/--cut`, `atlas_to_vd.py --body/--outline`. - Release path: reproducible clean-checkout setup, a procedural Blender regression scene for CI, then an alpha diff --git a/SECURITY.md b/SECURITY.md index 907ab5f..2158492 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,11 +9,18 @@ machine: |---|---| | Content Studio (`tools/uo-content/studio.py`) | `http://127.0.0.1:8772` | | Fit Lab (`tools/fit-lab/run.py serve`) | `http://127.0.0.1:8774` | - -Both bind to the loopback interface only and reject requests whose `Host` or `Origin` header is not -`127.0.0.1:` or `localhost:`, which blocks DNS-rebinding and cross-site requests from web pages. -State-changing requests accept JSON only. They are not designed to be exposed to a network: do not bind them to -`0.0.0.0`, port-forward them or put them behind a public reverse proxy. +| Live pose editor (`games/ultima-online/region-masks/pose_editor_server.py`) | `http://127.0.0.1:8768/editor/` | + +All three bind to the loopback interface only and reject requests whose `Host` or `Origin` header is not +`127.0.0.1:` or `localhost:`, which blocks DNS-rebinding and cross-site requests from web pages (GET, +HEAD and POST alike; the pose editor also requires an `Origin` on POST, and the Studio and pose editor share one +guard, `common/local_guard.py`). State-changing requests accept JSON only, the servers never list a directory and +static files are served only from the tool's own folders. They are not designed to be exposed to a network: do not +bind them to `0.0.0.0`, port-forward them or put them behind a public reverse proxy. + +Trusted local user: a few features take a path from the request on purpose, namely Fit Lab `/api/assets` (import a +folder of parts you name) and Content Studio `/api/stage` (stage into a client folder you name). They assume the +person at the keyboard is the one asking; the guard above is what keeps a web page from asking on their behalf. The tools read and write files under the repository's ignored `workspace/` folder, the local asset-pack sidecar (`SPRITEMOTION_SIDECAR`) and folders you choose. Client staging writes to a new output folder and never modifies the diff --git a/common/local_guard.py b/common/local_guard.py new file mode 100644 index 0000000..6ace032 --- /dev/null +++ b/common/local_guard.py @@ -0,0 +1,20 @@ +"""Loopback request guard shared by SpriteMotion's local web servers (standard library only). + +A request is local when its Host header is 127.0.0.1: or localhost: (blocks DNS rebinding) and its +Origin header, when present, names the same host. A state-changing request can also be required to carry an Origin. +""" +from __future__ import annotations + + +def allowed_hosts(port: int) -> set[str]: + return {f'127.0.0.1:{port}', f'localhost:{port}'} + + +def is_local(headers, port: int, require_origin: bool = False) -> bool: + hosts = allowed_hosts(port) + if headers.get('Host') not in hosts: + return False + origin = headers.get('Origin') + if not origin: + return not require_origin + return origin in {'http://' + h for h in hosts} diff --git a/games/ultima-online/outfit-lab/ITEMS.md b/games/ultima-online/outfit-lab/ITEMS.md index e3aff9b..2c1c59d 100644 --- a/games/ultima-online/outfit-lab/ITEMS.md +++ b/games/ultima-online/outfit-lab/ITEMS.md @@ -54,7 +54,7 @@ Examples measured on one shard's client. Check yours, because shards and client |---|---|---| | Clothing, armour, robes: the shape stays, the material changes | texture transfer, `fit_texture` | `build_item.py` (one item) or `build.py` | | Flat item: shield, banner | whole picture laid on the item, `fit_planar` | `build_item.py --planar` | -| Slender hand-held weapon: sword, staff, spear (long straight axis) | axis fit, `fit_lightsaber` | `build.py --config` with `axisFit` | +| Slender hand-held weapon: sword, staff, spear (long straight axis) | axis fit, `fit_energy_blade` | `build.py --config` with `axisFit` | | Helmet with different views | one picture per direction (`fit_helmet`) | see `build_plate_armor.py` | | Shorter or ragged hanging item (cloak) | shape step, `shorten_frayed` | `build_item.py --cut/--fray` | @@ -121,12 +121,12 @@ a BOM is fine. PowerShell 5.1's `Set-Content -Encoding utf8` writes one. To writ Start with a few actions (0 walk, 4 idle, 9 slash 1h, 13 slash 2h, 16 spell), then build all 35. ```powershell -& $py games\ultima-online\outfit-lab\build.py --out "$w\lab" --design sheet.png --lightsaber sword.png --config my.json --actions 0 4 9 13 +& $py games\ultima-online\outfit-lab\build.py --out "$w\lab" --design sheet.png --energy-blade sword.png --config my.json --actions 0 4 9 13 & $py games\ultima-online\outfit-lab\verify.py "$w\lab" & $py -m http.server 8780 --bind 127.0.0.1 --directory "$w\lab" # then http://127.0.0.1:8780 ``` -`--design` and `--lightsaber` must point to existing files even when the config does not use them. +`--design` and `--energy-blade` must point to existing files even when the config does not use them. `verify.py` must report `"errors": []` and `missingSequences: 0`. It exits with 1 when an item's pixels did not change at all. After rebuilding, reload the viewer with Ctrl+F5 or `?v=N`. diff --git a/games/ultima-online/outfit-lab/README.md b/games/ultima-online/outfit-lab/README.md index acb5b15..9420ee9 100644 --- a/games/ultima-online/outfit-lab/README.md +++ b/games/ultima-online/outfit-lab/README.md @@ -3,7 +3,7 @@ The Astral Wayfarer prototype uses original body-400 animations and equipment silhouettes, transfers a generated outfit design onto them, and previews original and custom artwork side by side. Every item has Off / UO / New controls. -The robe and separates share a design; staff and red lightsaber are alternative +The robe and separates share a design; staff and red energy blade are alternative weapons. The UO side retains the original broadsword for A/B comparison. The hat is excluded from the current demo, presets, design gallery and polish scope. A flag backpack and a floating crystal familiar demonstrate attached props. @@ -42,7 +42,7 @@ the entire composited canvas, matching the x=127.5 pixel reflection. The historical design sheet is a regular 4-by-3 grid, indexed by DESIGN_CELLS, with the final cell reserved for a character concept. Its hat cell is skipped. -The separate `--lightsaber` transparent image replaces the original sword design. +The separate `--energy-blade` transparent image replaces the original sword design. It is registered to each native weapon's principal axis, with the nearest visible hand choosing the hilt end (brightness is the fallback). Source projected length preserves foreshortening; generated red blade and halo can extend outside the diff --git a/games/ultima-online/outfit-lab/build.py b/games/ultima-online/outfit-lab/build.py index be3d264..ba34252 100644 --- a/games/ultima-online/outfit-lab/build.py +++ b/games/ultima-online/outfit-lab/build.py @@ -23,8 +23,8 @@ FACING = [3,4,5,6,7] DESIGN_CELLS = dict(zip(['sword','staff','robe','hat','hair','shirt','pants','shoes','gloves','backpack','familiar'],range(11))) -def fit_lightsaber(original, design, labels, width_ratio=.15, thickness_px=None, state=None): - """Register the generated straight saber to the source weapon axis and grip. +def fit_energy_blade(original, design, labels, width_ratio=.15, thickness_px=None, state=None): + """Register the generated straight energy blade to the source weapon axis and grip. Hand-mask proximity chooses the hilt end. Native brightness breaks ties or handles frames without visible hands. The source's projected length retains @@ -72,7 +72,7 @@ def fit_lightsaber(original, design, labels, width_ratio=.15, thickness_px=None, return design.transform(original.size,Image.Transform.AFFINE,coeff,Image.Resampling.BICUBIC) def fit_planar(original, design, state=None): - """Lay a whole flat picture (shield, banner) onto the original item, like fit_lightsaber does for a shaft. + """Lay a whole flat picture (shield, banner) onto the original item, like fit_energy_blade does for a shaft. The item's silhouette gives a long axis (design top -> bottom) and a cross extent (design left -> right); the picture is warped onto that frame, so foreshortening comes from the silhouette. The original alpha is kept @@ -248,11 +248,11 @@ def build(args): box=tile.getbbox() if not box: raise ValueError(f'Empty design: {key}') designs[key]=tile.crop(box);designs[key].save(out/'designs'/f'{key}.png') - saber=Image.open(args.lightsaber).convert('RGBA') + blade=Image.open(args.energy_blade).convert('RGBA') # Ignore nearly invisible halo pixels when deriving the asset registration. - support=saber.getchannel('A').point(lambda a:255 if a>24 else 0).getbbox() - if support is None: raise ValueError('Lightsaber design is empty') - designs['sword']=saber.crop(support) + support=blade.getchannel('A').point(lambda a:255 if a>24 else 0).getbbox() + if support is None: raise ValueError('Energy blade design is empty') + designs['sword']=blade.crop(support) designs['sword'].save(out/'designs/sword.png') # Other slender hand-held items can use the same axis fit (config: axisFit, axisImages, axisRatio). axis=set(cfg.get('axisFit',['sword'])) @@ -263,7 +263,7 @@ def build(args): designs[k]=img;designs[k].save(out/'designs'/f'{k}.png') root=Path(args.source);reader=UOReader(root) items=[dict(key=k,**reader.item(g)) for k,g in items_cfg] - items[0]['displayName']=cfg.get('displayNames',{}).get(items[0]['key'],'Red lightsaber') + items[0]['displayName']=cfg.get('displayNames',{}).get(items[0]['key'],'Red energy blade') for it in items: if it['key'] in cfg.get('displayNames',{}): it['displayName']=cfg['displayNames'][it['key']] refs={k:static_art(root,g) for k,g in items_cfg if k in props} @@ -273,7 +273,7 @@ def build(args): 'items':items,'actions':[],'rows':{'body':0,'mask':1}, **{k:cfg[k] for k in ('drawOrder','defaultOff','exclusive') if k in cfg}, 'limitations':['Body 400 only; estimated region labels are not ground-truth depth.', - 'Clothes retain original equipment alpha; generated textures are fitted in 2D. The lightsaber uses the original weapon axis and estimated hand anchor.', + 'Clothes retain original equipment alpha; generated textures are fitted in 2D. The energy blade uses the original weapon axis and estimated hand anchor.', *(['Backpack has no usable wearable animation here: static item reference, custom anchored overlay.'] if 'backpack' in props else []), 'Facing layer policies are experimental, not a full ClassicUO equipment renderer.', 'Playback FPS is adjustable; client movement/combat timing is not simulated.']} @@ -281,7 +281,7 @@ def build(args): for filename in ['anim.idx','tiledata.mul','Equipconv.def']: report['hashes'][filename]=hashlib.sha256((root/filename).read_bytes()).hexdigest() report['hashes']['design']=hashlib.sha256(Path(args.design).read_bytes()).hexdigest() - report['hashes']['lightsaber']=hashlib.sha256(Path(args.lightsaber).read_bytes()).hexdigest() + report['hashes']['energy_blade']=hashlib.sha256(Path(args.energy_blade).read_bytes()).hexdigest() masks=Path(args.masks) try: for action in actions: @@ -314,7 +314,7 @@ def build(args): new=prop(designs[k],labels,k,f,facing) else: original=canvas(seqs[k][f]) if len(seqs[k])==n else Image.new('RGBA',(256,256)) - new=fit_lightsaber(original,designs[k],labels,cfg.get('axisRatio',{}).get(k,args.width_ratio),cfg.get('axisThickness',{}).get(k),astate.get(k)) if k in axis else fit_texture(original,designs[k]) + new=fit_energy_blade(original,designs[k],labels,cfg.get('axisRatio',{}).get(k,args.width_ratio),cfg.get('axisThickness',{}).get(k),astate.get(k)) if k in axis else fit_texture(original,designs[k]) # Preserve exposed hands/face where a new cloth texture overlaps. ids=hide.get(k,[]) before=np.count_nonzero(np.array(new)[:,:,3]);new=occlude(new,labels,ids) @@ -341,7 +341,7 @@ def build(args): p.add_argument('--source',default=os.environ.get('SPRITEMOTION_UO_SOURCE')) p.add_argument('--out',default=str(REPO/'workspace/ultima-online/outfit-lab')) p.add_argument('--design',default=str(REPO/'workspace/ultima-online/outfit-lab/design.png')) - p.add_argument('--lightsaber',default=str(REPO/'workspace/ultima-online/outfit-lab/lightsaber.png')) + p.add_argument('--energy-blade',default=str(REPO/'workspace/ultima-online/outfit-lab/energy-blade.png')) p.add_argument('--masks',default=str(REPO/'workspace/ultima-online/region-audit/all-actions-region-pass/frames')) p.add_argument('--actions',nargs='+',type=int) p.add_argument('--config',help='JSON: items [[key,graphic]], cells {key:index}, props, hide {key:[region ids]}, drawOrder, defaultOff, exclusive, title, displayNames') diff --git a/games/ultima-online/outfit-lab/build_overalls.py b/games/ultima-online/outfit-lab/build_overalls.py index ec16cd6..a126964 100644 --- a/games/ultima-online/outfit-lab/build_overalls.py +++ b/games/ultima-online/outfit-lab/build_overalls.py @@ -77,7 +77,7 @@ def main(): m['report']=report (OUT/'manifest.json').write_text(json.dumps(m,indent=2));(OUT/'data.js').write_text('window.OUTFIT='+json.dumps(m)+';') for name in ['index.html','viewer.js','style.css']:shutil.copyfile(HERE/name,OUT/name) - p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Overalls Outfit').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red shirt, blue overalls, white gloves, brown shoes and mustache. Cap unavailable: its render was rejected.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Mustache is a custom layer with no original UO counterpart.').replace('','');p.write_text(s,encoding='utf-8') + p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Overalls Outfit').replace('Red energy blade and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red shirt, blue overalls, white gloves, brown shoes and mustache. Cap unavailable: its render was rejected.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Mustache is a custom layer with no original UO counterpart.').replace('','');p.write_text(s,encoding='utf-8') print(json.dumps(report)) if __name__=='__main__':main() diff --git a/games/ultima-online/outfit-lab/build_plate_armor.py b/games/ultima-online/outfit-lab/build_plate_armor.py index 9e9368b..49ec6f7 100644 --- a/games/ultima-online/outfit-lab/build_plate_armor.py +++ b/games/ultima-online/outfit-lab/build_plate_armor.py @@ -6,7 +6,7 @@ import numpy as np from PIL import Image from uo import client_source -from build import REPO, HERE, UOReader, canvas, fit_texture, fit_lightsaber, occlude +from build import REPO, HERE, UOReader, canvas, fit_texture, fit_energy_blade, occlude OUT=REPO/'workspace/ultima-online/plate-armor-lab' MASKS=REPO/'workspace/ultima-online/region-audit/all-actions-region-pass/frames' @@ -69,7 +69,7 @@ def main(): atlas.paste(Image.fromarray(vis),(f*256,256)) for j,item in enumerate(items): k=item['key'];original=canvas(seq[k][f]) - if k=='sword':new=fit_lightsaber(original,designs[k],labels,width_ratio=.42) + if k=='sword':new=fit_energy_blade(original,designs[k],labels,width_ratio=.42) elif k=='helmet':new=fit_helmet(original,helmets[stored]) else:new=fit_texture(original,designs['back'] if k=='chest' and stored>=3 else designs[k]) exclude={'chest':[1,5],'arms':[1,5],'legs':[5],'boots':[5],'helmet':[5],'sword':[5]}.get(k,[]) @@ -87,7 +87,7 @@ def main(): m['report']=report (OUT/'manifest.json').write_text(json.dumps(m,indent=2));(OUT/'data.js').write_text('window.OUTFIT='+json.dumps(m)+';') for name in ['index.html','viewer.js','style.css']:shutil.copyfile(HERE/name,OUT/name) - p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Sci-fi Plate Armor').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Sci-fi green plate armor and directional gold-visored helmet. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Pane A uses original UO plate armor, helmet and broadsword animations.').replace('','');p.write_text(s,encoding='utf-8') + p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Sci-fi Plate Armor').replace('Red energy blade and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Sci-fi green plate armor and directional gold-visored helmet. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','Pane A uses original UO plate armor, helmet and broadsword animations.').replace('','');p.write_text(s,encoding='utf-8') print(json.dumps(report)) if __name__=='__main__':main() diff --git a/games/ultima-online/outfit-lab/build_tracksuit.py b/games/ultima-online/outfit-lab/build_tracksuit.py index e8253c6..98c77e7 100644 --- a/games/ultima-online/outfit-lab/build_tracksuit.py +++ b/games/ultima-online/outfit-lab/build_tracksuit.py @@ -7,7 +7,7 @@ import numpy as np from PIL import Image from uo import client_source -from build import REPO, HERE, UOReader, canvas, fit_texture, fit_lightsaber, occlude +from build import REPO, HERE, UOReader, canvas, fit_texture, fit_energy_blade, occlude OUT=REPO/'workspace/ultima-online/tracksuit-lab' MASKS=REPO/'workspace/ultima-online/region-audit/all-actions-region-pass/frames' @@ -83,7 +83,7 @@ def main(): originals['chain']=Image.new('RGBA',(256,256)) for j,item in enumerate(items): k=item['key'];original=originals[k] - if k=='sword':new=fit_lightsaber(original,designs[k],labels,width_ratio=.42) + if k=='sword':new=fit_energy_blade(original,designs[k],labels,width_ratio=.42) elif k=='chain':new=chain_image(designs[k],labels,facing) elif k=='shirt':new=cloth(original,designs[k],np.array(originals['sleeves'])[:,:,3]>0) elif k=='pants':new=cloth(original,designs[k],np.array(original)[:,:,3]>0) @@ -103,7 +103,7 @@ def main(): m['report']=report (OUT/'manifest.json').write_text(json.dumps(m,indent=2));(OUT/'data.js').write_text('window.OUTFIT='+json.dumps(m)+';') for name in ['index.html','viewer.js','style.css']:shutil.copyfile(HERE/name,OUT/name) - p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Crimson Runner').replace('Red lightsaber and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red tracksuit, white arm and leg stripes, gold chain and sci-fi energy sword. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','The gold chain is a new torso overlay with no original UO animation. The original top combines a shirt and leather sleeves.').replace('','');p.write_text(s,encoding='utf-8') + p=OUT/'index.html';s=p.read_text(encoding='utf-8').replace('Astral Wayfarer','Crimson Runner').replace('Red energy blade and staff are alternatives. Robe covers the shirt and pants. Hide it to inspect the separates.','Red tracksuit, white arm and leg stripes, gold chain and sci-fi energy sword. Toggle each piece independently.').replace('','').replace('','').replace('Backpack and familiar originals are static item art placed at the same attachment point.','The gold chain is a new torso overlay with no original UO animation. The original top combines a shirt and leather sleeves.').replace('','');p.write_text(s,encoding='utf-8') print(json.dumps(report)) if __name__=='__main__':main() diff --git a/games/ultima-online/outfit-lab/index.html b/games/ultima-online/outfit-lab/index.html index 711f058..1799d13 100644 --- a/games/ultima-online/outfit-lab/index.html +++ b/games/ultima-online/outfit-lab/index.html @@ -1,6 +1,6 @@ Astral Wayfarer · Outfit Lab
SPRITEMOTION / ULTIMA ONLINE

Astral Wayfarer Outfit lab

Original animation. New wardrobe. Inspect every layer.

-
+

A Original UO references

B Your selected outfit

Loading…
diff --git a/games/ultima-online/outfit-lab/make_gump.py b/games/ultima-online/outfit-lab/make_gump.py index 025e31c..c565159 100644 --- a/games/ultima-online/outfit-lab/make_gump.py +++ b/games/ultima-online/outfit-lab/make_gump.py @@ -46,7 +46,7 @@ def read_gump(client, gump_id): def butt_marker(shape, alpha, where): - """Label image with one 'hand' pixel at the butt end; fit_lightsaber puts the item's left end there.""" + """Label image with one 'hand' pixel at the butt end; fit_energy_blade puts the item's left end there.""" ys, xs = np.nonzero(alpha) labels = np.zeros(shape, np.uint8) if where == 'bottom': @@ -99,7 +99,7 @@ def main(a): design = design.rotate(-90, expand=True) old_image = Image.fromarray(old) labels = butt_marker(old.shape[:2], old[..., 3] > 0, a.butt) - fitted = np.array(build.fit_lightsaber(old_image, design, labels, a.ratio, a.thickness)) + fitted = np.array(build.fit_energy_blade(old_image, design, labels, a.ratio, a.thickness)) fitted[..., 3] = np.where(fitted[..., 3] >= 128, 255, 0) if a.outline: solid = Image.fromarray(fitted[..., 3]) @@ -134,8 +134,8 @@ def main(a): p.add_argument('--thickness', type=float, default=None, help='item thickness in gump px (staff ~26)') p.add_argument('--ratio', type=float, default=.15, help='thickness as a fraction of length when --thickness is not given') p.add_argument('--butt', choices=['bottom', 'top', 'left', 'right'], default='bottom', help='which end of the original gump is the butt/hilt') - p.add_argument('--shift', default='0,0', help='dx,dy shift in px after fitting (e.g. 3,-9 for the saber)') - p.add_argument('--front-below', type=int, default=None, help='row from which the hand covers the item (saber: 106)') + p.add_argument('--shift', default='0,0', help='dx,dy shift in px after fitting (e.g. 3,-9 for the energy blade)') + p.add_argument('--front-below', type=int, default=None, help='row from which the hand covers the item (energy blade: 106)') p.add_argument('--outline', action='store_true', help='add a 1 px dark outline') a = p.parse_args(); a.client = client_source(a.client) main(a) diff --git a/games/ultima-online/outfit-lab/test_fitting.py b/games/ultima-online/outfit-lab/test_fitting.py index 344234f..49d8c4f 100644 --- a/games/ultima-online/outfit-lab/test_fitting.py +++ b/games/ultima-online/outfit-lab/test_fitting.py @@ -1,7 +1,7 @@ import unittest import numpy as np from PIL import Image -from build import canvas, fit_texture, fit_lightsaber, occlude +from build import canvas, fit_texture, fit_energy_blade, occlude class FittingTests(unittest.TestCase): def test_ground_origin(self): @@ -27,18 +27,18 @@ def test_empty_frame_stays_empty(self): im=Image.new('RGBA',(256,256)) self.assertIsNone(fit_texture(im,Image.new('RGBA',(10,10),'red')).getbbox()) - def test_saber_grip_faces_hand_and_blade_reaches_tip(self): + def test_blade_grip_faces_hand_and_blade_reaches_tip(self): src=Image.new('RGBA',(256,256)) pixels=np.array(src);pixels[100,80:111]=[190,190,190,255] design=Image.new('RGBA',(100,10),'red') patch=np.array(design);patch[:,:20]=[0,255,0,255] labels=np.zeros((256,256),np.uint8);labels[100,111]=5 - result=np.array(fit_lightsaber(Image.fromarray(pixels),Image.fromarray(patch),labels)) + result=np.array(fit_energy_blade(Image.fromarray(pixels),Image.fromarray(patch),labels)) self.assertGreater(result[100,109,1],result[100,109,0]) self.assertGreater(result[100,82,0],result[100,82,1]) - def test_empty_saber_does_not_invent_blade(self): - result=fit_lightsaber(Image.new('RGBA',(256,256)),Image.new('RGBA',(100,10),'red'),np.zeros((256,256),np.uint8)) + def test_empty_blade_does_not_invent_blade(self): + result=fit_energy_blade(Image.new('RGBA',(256,256)),Image.new('RGBA',(100,10),'red'),np.zeros((256,256),np.uint8)) self.assertIsNone(result.getbbox()) if __name__=='__main__':unittest.main() diff --git a/games/ultima-online/region-masks/pose_editor_server.py b/games/ultima-online/region-masks/pose_editor_server.py index ef0f883..4889560 100644 --- a/games/ultima-online/region-masks/pose_editor_server.py +++ b/games/ultima-online/region-masks/pose_editor_server.py @@ -1,11 +1,14 @@ """Loopback-only live pose editor, local saves, and asynchronous Blender export.""" from pathlib import Path -import argparse, functools, json, math, os, re, shutil, subprocess, threading, time, uuid +import argparse, functools, importlib.util, json, math, os, re, shutil, subprocess, threading, time, uuid from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs ROOT=Path(__file__).resolve().parents[3] OUT=ROOT/'workspace/ultima-online/female-locomotion' +# One guard for every local server; loaded by path so this script runs without an installed `spritemotion`. +_spec=importlib.util.spec_from_file_location('local_guard',ROOT/'common/local_guard.py') +local_guard=importlib.util.module_from_spec(_spec);_spec.loader.exec_module(local_guard) SOURCE=Path(__file__).parent EDITABLE={f'{bone}_{side}' for bone in ('upperarm','lowerarm','hand','thigh','calf','foot') for side in ('l','r')}|{'LegPlate_L','LegPlate_R','Hip_L','Hip_R'} @@ -52,6 +55,17 @@ def __init__(self,port): super().__init__(('127.0.0.1',port),functools.partial(Handler,directory=str(OUT))) class Handler(SimpleHTTPRequestHandler): + # Loopback only: Host is checked on every request, Origin on GET/HEAD when sent and always on POST. + def local(self,post=False): + return local_guard.is_local(self.headers,self.server.server_port,require_origin=post) + def list_directory(self,path): + self.send_error(404,'Not found') # only named files are served, never a listing + def translate_path(self,path): + target=Path(super().translate_path(path)).resolve() + return str(target) if target.is_relative_to(OUT.resolve()) else str(OUT/'__not_found__') + def do_HEAD(self): + if not self.local():return self.send_json({'error':'Local requests only.'},403) + return super().do_HEAD() def end_headers(self): self.send_header('Cache-Control','no-store') self.send_header('X-Content-Type-Options','nosniff') @@ -59,6 +73,7 @@ def end_headers(self): def send_json(self,value,status=200): data=json.dumps(value).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) def do_GET(self): + if not self.local():return self.send_json({'error':'Local requests only.'},403) parsed=urlparse(self.path) if parsed.path=='/api/edits': p=OUT/'editor/edits.json' @@ -73,9 +88,10 @@ def do_GET(self): raw=(SOURCE/source).read_bytes();self.send_response(200);self.send_header('Content-Type',{'html':'text/html','js':'text/javascript','css':'text/css'}[source.rsplit('.',1)[1]]);self.send_header('Content-Length',str(len(raw)));self.end_headers();self.wfile.write(raw);return return super().do_GET() def do_POST(self): + if not self.local(post=True):return self.send_json({'error':'Local requests only.'},403) if self.path not in ('/api/save','/api/bake'):return self.send_json({'error':'Unknown route'},404) - origin=self.headers.get('Origin') - if origin and origin!=f'http://127.0.0.1:{self.server.server_port}':return self.send_json({'error':'Origin rejected'},403) + # JSON-only requests prevent cross-origin forms from changing local edits. + if self.headers.get_content_type()!='application/json':return self.send_json({'error':'Expected JSON.'},415) try: size=int(self.headers.get('Content-Length','0')) if not 0 (or set SPRITEMOTION_FIT_PACK)` | | `editor/open-godot-project` | Open the Sprite Pose Editor's source project in the Godot editor (starts the editor window and returns). | | | `editor/outfit-lab` | Open the outfit lab page (workspace\ultima-online\outfit-lab\index.html) in your browser, or say how to build it when it is missing. | | +| `editor/overalls-lab` | Open the overalls lab page (workspace\ultima-online\overalls-lab\index.html) in your browser, or say how to build it when it is missing. | | | `editor/sample-character` | Open the Sprite Pose Editor (a Godot window) on the bundled, redistributable sample character. | | +| `editor/plate-armor-lab` | Open the sci-fi plate armor lab page (workspace\ultima-online\plate-armor-lab\index.html) in your browser, or say how to build it when it is missing. | | | `editor/sprite-pose-editor` | THE launcher: open the Sprite Pose Editor (a Godot window that opens and returns) on a dataset. | `[dataset folder] (default: SPRITEMOTION_DATASET)` | | `editor/tracksuit-lab` | Open the tracksuit lab page (workspace\ultima-online\tracksuit-lab\index.html) in your browser, or say how to build it when it is missing. | | | `editor/workbench` | Start Content Studio and Fit Lab together for a pack, open both in your browser, and keep them running here until Ctrl+C. | `[pack] (default: SPRITEMOTION_FIT_PACK, else the bundled cc0-starter)` | @@ -68,7 +70,6 @@ Content tools (Content Studio, Fit Lab, the workbench) are described in [editor/ ## Not launchers -- `editor/mario-lab.bat` and `editor/spartan-lab.bat` are left as they were; PR #4 replaces them. - Fit Lab's poke **Measure** runs in the browser (there is no command line for it), so it has no launcher: start `editor/fit-lab` and press Measure. - `tools/vd/vdtool.py`, `tools/vd/mul2vd.py` and `tools/godot/fetch.py` are helper scripts with their own usage in `tools/vd/README.md` and `tools/godot/README.md` (`pipeline/0-setup` calls `fetch.py`). - `games/ultima-online/outfit-lab/build_*.py` take per-outfit arguments and are documented in that folder's README. diff --git a/launchers/editor/overalls-lab.bat b/launchers/editor/overalls-lab.bat index cb34f42..37aade0 100644 --- a/launchers/editor/overalls-lab.bat +++ b/launchers/editor/overalls-lab.bat @@ -1,9 +1,8 @@ @echo off -@rem Opens the offline overalls outfit preview built by games\ultima-online\outfit-lab\build_overalls.py. +rem Open the overalls lab page (workspace\ultima-online\overalls-lab\index.html) in your browser, or say how to build it when it is missing. call "%~dp0..\_shared\common.bat" || exit /b 1 if not exist "workspace\ultima-online\overalls-lab\index.html" ( - echo Build with python games\ultima-online\outfit-lab\build_overalls.py first. - pause + echo Build the overalls lab first with python games\ultima-online\outfit-lab\build_overalls.py exit /b 1 ) start "" "workspace\ultima-online\overalls-lab\index.html" diff --git a/launchers/editor/overalls-lab.sh b/launchers/editor/overalls-lab.sh new file mode 100755 index 0000000..e73164c --- /dev/null +++ b/launchers/editor/overalls-lab.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Open the overalls lab page (workspace/ultima-online/overalls-lab/index.html) in your browser, or say how to build it when it is missing. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/../_shared/common.sh" +if [ ! -f workspace/ultima-online/overalls-lab/index.html ]; then + echo "Build the overalls lab first with python games/ultima-online/outfit-lab/build_overalls.py" >&2; exit 1 +fi +sm_open workspace/ultima-online/overalls-lab/index.html diff --git a/launchers/editor/plate-armor-lab.bat b/launchers/editor/plate-armor-lab.bat index 57921d7..5a6c8b8 100644 --- a/launchers/editor/plate-armor-lab.bat +++ b/launchers/editor/plate-armor-lab.bat @@ -1,9 +1,8 @@ @echo off -@rem Opens the offline sci-fi plate armor preview built by games\ultima-online\outfit-lab\build_plate_armor.py. +rem Open the sci-fi plate armor lab page (workspace\ultima-online\plate-armor-lab\index.html) in your browser, or say how to build it when it is missing. call "%~dp0..\_shared\common.bat" || exit /b 1 if not exist "workspace\ultima-online\plate-armor-lab\index.html" ( - echo Build with python games\ultima-online\outfit-lab\build_plate_armor.py first. - pause + echo Build the sci-fi plate armor lab first with python games\ultima-online\outfit-lab\build_plate_armor.py exit /b 1 ) start "" "workspace\ultima-online\plate-armor-lab\index.html" diff --git a/launchers/editor/plate-armor-lab.sh b/launchers/editor/plate-armor-lab.sh new file mode 100755 index 0000000..b8bb6dd --- /dev/null +++ b/launchers/editor/plate-armor-lab.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Open the sci-fi plate armor lab page (workspace/ultima-online/plate-armor-lab/index.html) in your browser, or say how to build it when it is missing. +set -euo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/../_shared/common.sh" +if [ ! -f workspace/ultima-online/plate-armor-lab/index.html ]; then + echo "Build the sci-fi plate armor lab first with python games/ultima-online/outfit-lab/build_plate_armor.py" >&2; exit 1 +fi +sm_open workspace/ultima-online/plate-armor-lab/index.html diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 232b8bb..f685eec 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -95,8 +95,7 @@ def test_agent_routers_match_claude_sources(): # ---- launchers: the DirectorDeck Run panel lists every .bat (rem line), and each has a .sh twin ---------------------- LAUNCHER_DIR = REPO / "launchers" -# PR #4 deletes these two; until then they are left as they were. -LAUNCHER_EXEMPT = {"editor/mario-lab", "editor/spartan-lab"} +LAUNCHER_EXEMPT: set[str] = set() COMMON_BAT = 'call "%~dp0..\\_shared\\common.bat" || exit /b 1' diff --git a/tests/unit/test_local_servers.py b/tests/unit/test_local_servers.py new file mode 100644 index 0000000..4693ca0 --- /dev/null +++ b/tests/unit/test_local_servers.py @@ -0,0 +1,118 @@ +"""Pose editor and Content Studio servers: loopback Host/Origin guard, JSON-only POST, no listings, confined static files.""" +import functools +import http.client +import http.server +import importlib.util +import json +from pathlib import Path +import sys +import threading + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module + + +pose = load('pose_editor_server_under_test', ROOT / 'games/ultima-online/region-masks/pose_editor_server.py') +sys.path.insert(0, str(ROOT / 'tools/uo-content')) +studio = load('studio_under_test', ROOT / 'tools/uo-content/studio.py') + + +def serve(handler): + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def request(server, method, path, host=None, origin=None, body=None, content_type='application/json'): + conn = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=10) + headers = {'Host': host or f'127.0.0.1:{server.server_port}'} + if origin: headers['Origin'] = origin + if body is not None: headers['Content-Type'] = content_type + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse(); payload = response.read(); conn.close() + return response.status, payload + + +@pytest.fixture +def editor(tmp_path, monkeypatch): + out = tmp_path / 'out'; (out / 'editor').mkdir(parents=True); (out / 'sub').mkdir() + (out / 'sub/file.txt').write_text('x', encoding='utf-8') + (tmp_path / 'secret.txt').write_text('secret', encoding='utf-8') + monkeypatch.setattr(pose, 'OUT', out) + server = serve(functools.partial(pose.Handler, directory=str(out))) + server.scene = {'assetId': 'a', 'clips': {}}; server.jobs = {}; server.lock = threading.Lock() + yield server + server.shutdown(); server.server_close() + + +@pytest.fixture +def content_studio(): + server = serve(studio.Handler) + yield server + server.shutdown(); server.server_close() + + +def test_editor_serves_local_requests(editor): + port = editor.server_port + assert request(editor, 'GET', '/api/edits')[0] == 200 + assert request(editor, 'GET', '/api/edits', host=f'localhost:{port}')[0] == 200 + assert request(editor, 'GET', '/sub/file.txt', origin=f'http://127.0.0.1:{port}')[0] == 200 + assert request(editor, 'GET', '/editor/')[0] == 200 + + +@pytest.mark.parametrize('method,path', [('GET', '/api/edits'), ('GET', '/api/job?id=x'), ('GET', '/editor/'), + ('GET', '/sub/file.txt'), ('HEAD', '/sub/file.txt')]) +def test_editor_rejects_bad_host_and_origin(editor, method, path): + assert request(editor, method, path, host='evil.example')[0] == 403 + assert request(editor, method, path, host=f'127.0.0.1:{editor.server_port + 1}')[0] == 403 + assert request(editor, method, path, origin='http://evil.example')[0] == 403 + + +def test_editor_post_needs_a_local_origin_and_json(editor): + port = editor.server_port + good = f'http://127.0.0.1:{port}' + assert request(editor, 'POST', '/api/save', body='{}')[0] == 403 # Origin is required on POST + assert request(editor, 'POST', '/api/save', origin='http://evil.example', body='{}')[0] == 403 + assert request(editor, 'POST', '/api/save', host='evil.example', origin=good, body='{}')[0] == 403 + assert request(editor, 'POST', '/api/save', origin=good, body='{}', content_type='text/plain')[0] == 415 + assert request(editor, 'POST', '/api/save', origin=good, body='{}', content_type='application/x-www-form-urlencoded')[0] == 415 + assert request(editor, 'POST', '/api/save', origin=good, body='{}')[0] == 400 # guard passed; bad edits rejected + assert request(editor, 'POST', '/api/other', origin=good, body='{}')[0] == 404 + + +def test_editor_has_no_listing_and_stays_inside_its_folder(editor): + assert request(editor, 'GET', '/sub/')[0] == 404 + assert request(editor, 'GET', '/')[0] == 404 + assert request(editor, 'GET', '/sub/file.txt')[0] == 200 + assert request(editor, 'GET', '/../secret.txt')[0] == 404 + assert request(editor, 'GET', '/%2e%2e/secret.txt')[0] == 404 + + +def test_studio_head_applies_the_host_check(content_studio): + assert request(content_studio, 'HEAD', '/api/config', host='evil.example')[0] == 403 + assert request(content_studio, 'HEAD', '/api/config', origin='http://evil.example')[0] == 403 + assert request(content_studio, 'GET', '/api/config', host='evil.example')[0] == 403 + assert request(content_studio, 'GET', '/api/config')[0] == 200 + + +def test_studio_rejects_cross_origin_posts_and_listings(content_studio): + assert request(content_studio, 'POST', '/api/cancel', origin='http://evil.example', body='{}')[0] == 403 + assert request(content_studio, 'POST', '/api/cancel', host='evil.example', body='{}')[0] == 403 + assert request(content_studio, 'POST', '/api/cancel', body='{}', content_type='text/plain')[0] == 415 + assert request(content_studio, 'GET', '/jobs/')[0] == 404 + assert request(content_studio, 'GET', '/')[0] == 200 + + +def test_shared_guard(): + guard = load('local_guard_under_test', ROOT / 'common/local_guard.py') + assert guard.is_local({'Host': '127.0.0.1:1'}, 1) + assert not guard.is_local({'Host': '127.0.0.1:1'}, 1, require_origin=True) + assert guard.is_local({'Host': 'localhost:1', 'Origin': 'http://localhost:1'}, 1, require_origin=True) + assert not guard.is_local({'Host': '127.0.0.1:2'}, 1) diff --git a/tools/uo-content/studio.py b/tools/uo-content/studio.py index 1f04106..2b29563 100644 --- a/tools/uo-content/studio.py +++ b/tools/uo-content/studio.py @@ -4,6 +4,7 @@ from concurrent.futures import ThreadPoolExecutor from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import importlib.util import json from pathlib import Path import tempfile @@ -12,6 +13,9 @@ import starters pool=ThreadPoolExecutor(max_workers=1) +# One guard for every local server; loaded by path so the studio also runs where `spritemotion` is not installed. +_spec=importlib.util.spec_from_file_location('local_guard',Path(__file__).resolve().parents[2]/'common/local_guard.py') +local_guard=importlib.util.module_from_spec(_spec); _spec.loader.exec_module(local_guard) class Handler(SimpleHTTPRequestHandler): def __init__(self,*args,**kwargs): @@ -24,10 +28,11 @@ def reply(self,value,status=200): self.end_headers(); self.wfile.write(raw) def local(self): - expected={f'127.0.0.1:{self.server.server_port}',f'localhost:{self.server.server_port}'} - if self.headers.get('Host') not in expected: return False - origin=self.headers.get('Origin') - return not origin or origin in {'http://'+h for h in expected} + return local_guard.is_local(self.headers,self.server.server_port) + + def do_HEAD(self): + if not self.local(): return self.reply({'error':'Local requests only.'},403) + return super().do_HEAD() def do_GET(self): if not self.local(): return self.reply({'error':'Local requests only.'},403)