diff --git a/mac-patcher/.gitignore b/mac-patcher/.gitignore index 34bac69b0..ed156d277 100644 --- a/mac-patcher/.gitignore +++ b/mac-patcher/.gitignore @@ -3,4 +3,3 @@ __pycache__/ *.pyc *.bak *.bak-* -scan_bundles_*.out diff --git a/mac-patcher/README.md b/mac-patcher/README.md deleted file mode 100644 index 420bd3889..000000000 --- a/mac-patcher/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# mac-patcher - -Python patcher that injects Mac-compatibility fallbacks into a SWL TTS save -file. Works around the TTS Unity 6 (Berserk) shader bug where vanilla -AssetBundle Projectors (range rulers, cohesion halos, movement templates, -deployment zones, silhouettes) render as a magenta rectangle on macOS -because the custom shader bundled by Allen White (Dicewrench) fails to load -under Unity 6 Metal. - -## What it does - -For each affected object script in a `TS_Save_N.json`: - -- **Cohesion / Range / Maximum Move / Deployment / Silhouette**: routes the - visualization through a Mac-friendly path (vector lines + decal PNGs) - built into the Global script. The vanilla bundle path stays available - for per-seat opt-in (see "Mac TTS U6 Patch" floating panel). - -- **SIL button**: renames `click_function = "toggleSilhouettes"` to - `"macToggleSil"` + injects a forwarder. Empirically the engine silently - swallows the original click_function name in Mac mode (the button plays - its sound/animation but the function is never invoked). The rename - bypasses the issue entirely. - -- **clearSilhouette nil guard**: upstream `clearSilhouette()` derefs - `removeAttachments()[1]` which is nil when `silhouetteState` is true but - the physical silhouette attachment has been lost across save reloads. - Patched to guard the nil case + reset state in onload. - -- **Order Token Speed/Move button overrides**: inlines vanilla bodies of - `changeSpeed1/2/3` and `moveForward/Backwards/Left/Right` to capture - the clicking player's color (`macActivePlayerForMove`) for per-seat - routing. Inlined rather than wrapped because the List Builder copies the - block to Command Token Custom_Models which lack the vanilla - Order_Token function bodies (wrap+call_orig would crash with "attempt - to call a nil value"). - -## Usage - -```bash -# Setup once -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -# Patch a save -python3 patch_save_for_mac.py [--reload] -``` - -The patcher also mirrors the patched save into -`~/Library/Tabletop Simulator/Saves/TS_Save_N.json` (next free slot, or the -existing `SWL BETA - MAC PATCH` slot if present) so TTS picks it up in -Games -> Save & Load. - -`--reload` hot-pushes the patched scripts to a running TTS instance via the -External Editor API (no need to restart TTS). - -## Idempotence - -All patches are idempotent: a re-run on an already-patched save strips the -previous Mac patch block from Global (via marker regex) and re-injects -fresh. Per-object wrappers are also stripped before re-injection. - -## Files - -- `patch_save_for_mac.py` - main patcher -- `scan_bundles.py` - UnityPy scanner for the local TTS bundle cache -- `extract_projector_specs.py` - dump Projector material properties from a - bundle (used to discover the `_Arc` shader keyword for firing arc lines) -- `generate_overlay_assets.py` - regenerate the range/cohesion/etc. PNG - decals from authoritative SWL geometry -- `inspect_*.py` - debug helpers -- `retro-*.md` - retrospective notes on each subsystem rewrite -- `SHADER_INVENTORY.md` - list of custom shaders in the vanilla bundles diff --git a/mac-patcher/README.txt b/mac-patcher/README.txt new file mode 100644 index 000000000..249817fff --- /dev/null +++ b/mac-patcher/README.txt @@ -0,0 +1,78 @@ +mac-patcher +=========== + +A Python patcher that injects the Iron Squadron overlay module into a SWL TTS +save file. It is optional, it sits behind a single table-wide button that is off +by default, and with the button off the mod behaves exactly as it does today. + +It has nothing to do with the magenta bug any more. That one is fixed in the +bundles themselves, which is the mergeable part of this branch; see +mac-support-package/ and mac-patcher/tools/. This module is the separate offer +described in proposals-upstream.txt, kept here so you can look at it before +deciding whether you want any of it. + + +WHAT IT ADDS + + Cohesion on the five base sizes the mod does not cover. getCohesionLinks() + ships 27, 50 and 70 mm, so on 100, 120 and 150 mm bases and on the two oblong + ones, spawnCohesionRuler returns early and nothing appears. 35 units are in + that case. + + Cohesion that follows a model while it is being dragged, instead of + disappearing on pickup and coming back stale on drop. + + Deterministic toggles. The vanilla range and cohesion buttons respawn their + Projector rather than toggling it, so a double click leaves two of them. + + A maximum-move template anchored where the move started, rather than one that + follows the model as it goes. + + A white cohesion band at range 0.5 on the eight unit-leader range templates, + and matching rings on objective and condition tokens. + +Rendering goes through real Projectors, in bundles built the same way as the +repaired ones. Nothing is drawn in Lua. + + +USAGE + + # Setup once + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + + # Patch a save + python3 patch_save_for_mac.py [--reload] + +The patcher writes only to . Point that at a slot under +~/Library/Tabletop Simulator/Saves/ if you want TTS to pick it up in +Games -> Save & Load. + +--reload hot-pushes the patched scripts to a running TTS instance via the +External Editor API, so there is no need to restart TTS. + + +IDEMPOTENCE + +All patches are idempotent. A re-run on an already patched save strips the +previous block from Global via a marker regex and re-injects a fresh one. +Per-object wrappers are stripped before re-injection too. + + +ONE KNOWN STOPGAP + +The Order Token's ATTACK chain is routed through this module so that it does not +leave two overlapping rulers on screen. The feature itself dates from V1 and its +measurements are not accurate under V2 rules, so treat it as a stopgap to be +replaced or removed, not as a design. + + +FILES + + patch_save_for_mac.py the patcher + proposals-upstream.txt what we are offering to build, for the maintainers + tools/ the bundle repair chain, documented in tools/README.txt: + build, merge the per-platform SubShaders, graft a Metal + SubShader into a 2019.1 bundle, inventory the mod's + bundles, install the result into the local TTS cache diff --git a/mac-patcher/SHADER_INVENTORY.md b/mac-patcher/SHADER_INVENTORY.md deleted file mode 100644 index 926e86db4..000000000 --- a/mac-patcher/SHADER_INVENTORY.md +++ /dev/null @@ -1,59 +0,0 @@ -# SW Legion TTS Mod — Shader Inventory - -Scan effectué le 2026-05-07 sur `~/Library/Tabletop Simulator/Mods/Assetbundles/` (40 bundles `.unity3d`). -Outil : UnityPy 1.25.0, script `scan_bundles.py`. Output brut : `scan_bundles_2026-05-07.out`. - -## TL;DR -**9 shaders custom distincts**, **tous embarqués dans le `.unity3d` qui les utilise**. Donc : -- L'astuce "Always Included Shaders" du player TTS est **non-applicable** : ces shaders ne sont pas chargés depuis le pool du player, ils vivent dans les bundles. -- Le bug magenta vient du fait que le bytecode/code source de ces shaders custom ne fonctionne plus correctement sous Unity 6 + Metal sur Mac. -- Les overlays magenta listés par Martin (cohésion, range, deployment, movement) correspondent **exactement** aux 4 shaders `BucketheadBits/Projector/*`. - -## Inventaire complet (par catégorie) - -### Shaders Projector (overlays affectés par le bug magenta) -| Shader | Materials | Bundles | -|---|---|---| -| `BucketheadBits/Projector/Movement` | `ProjectorMaterial_27mm_speed1_single`, `ProjectorMaterial_27mm_speed2_single`, `ProjectorMaterial_50mm_speed2_single` | 3 | -| `BucketheadBits/Projector/Deployment` | `Projector_Deployment_Blue` (×2), `Projector_Deployment_Red` (×2) | 4 | -| `BucketheadBits/Projector/Range` | `ProjectorMaterial_25mm_token`, `ProjectorMaterial_27mm`, `ProjectorMaterial_50mm` | 3 | -| `BucketheadBits/Projector/Cohesion` | `Cohesion_27mm` | 1 | - -→ 11 materials projector au total, 4 shaders distincts à porter en priorité. - -### Outils / autres custom -| Shader | Materials | Bundles | -|---|---|---| -| `BucketheadBits/MoveTool` | `Speed1`, `Speed2` | 2 | -| `BucketheadBits/Tokens/Lambert Channel Mask` | `unitIDtoken_1` à `unitIDtoken_10` | 10 | -| `BucketheadBits/Silhouette` | `BucketheadBits_Silhouette` | 1 | -| `BucketheadBits/Units/Color Replacer` | `Ewok_SharedMat` | 1 | -| `DWD/LightenSkybox` | `Skybox` | 1 | - -→ Le shader `Tokens/Lambert Channel Mask` correspond aux 10 unitIDtokens loggés en console par TTS lors du bug : "Shader didn't load correctly for AssetBundle material unitIDtoken_X". Donc ce shader est **explicitement confirmé cassé**. - -## Auteurs -- Préfixe **`BucketheadBits/`** sur 8/9 shaders → ancien contributeur "Buckethead". Aucun repo GitHub public trouvé sous ce handle. Possible que ce soit un handle alternatif de Tieren (crédité ailleurs comme "original mod creator"). -- Préfixe **`DWD/`** sur 1 shader (LightenSkybox) → autre auteur, juste un tint skybox. - -## Implications pour la conversation avec Ben - -1. **L'angle "Always Included Shaders" n'est pas la bonne piste** pour ce bug, parce que les shaders custom sont embarqués dans les bundles. Si Ben a essayé cette piste, c'est cohérent que ça n'ait rien donné. - -2. **Le scope minimum réaliste** : porter / remplacer ces 9 shaders sous Unity 6 (compilation Metal-compatible). Soit en partant des sources s'il les a, soit en réécrivant des équivalents stock. - -3. **Materials = 11 projectors + 10 tokens + 4 outils + 1 skybox = 26 materials affectés au total** dans 27 bundles différents. Volumétrie raisonnable si les shaders sont swappable d'un coup. - -4. **Type technique des shaders** : - - Les `Projector/*` sont des shaders Unity Projector (transparent, projection avec bandes alternées) — historiquement tricky à porter Metal. - - Le `Tokens/Lambert Channel Mask` est un shader de coloration par canaux (R/G/B/A) — utilisé pour appliquer la couleur du joueur sur un mesh à canaux. Classique mais codé custom. - - Les autres (MoveTool, Silhouette, Color Replacer, LightenSkybox) sont des shaders d'effets simples. - -## Sources non-trouvées -- Aucun repo public sous `swlegion`, `swlegion-dev`, `matanlurey`, `halestom15`, `Buckethead` ne contient les sources Unity de ces shaders. -- Le projet Unity vit donc en privé sur la machine d'un contributeur (probablement Decaf ou Ben). - -## Prochaine étape -Attendre Ben pour confirmer si les sources sont disponibles. Cet inventaire répond déjà partiellement à ses questions 1 et 2 : -- Q1 ("custom shaders updated or just retargeted ?") : on sait maintenant exactement ce qu'il faut updater. -- Q2 ("which shaders are referenced ?") : liste complète ci-dessus. diff --git a/mac-patcher/design-refactor.md b/mac-patcher/design-refactor.md deleted file mode 100644 index a94b2d03a..000000000 --- a/mac-patcher/design-refactor.md +++ /dev/null @@ -1,330 +0,0 @@ -# Design refactor — Overlays Projector SWL TTS (Mac/Unity 6) - -**Status** : draft 15 mai 2026. Spec pour le patch event-driven hybride (decals plats + vector lines drapants). Base : 4 retro-*.md + materials-reference.md + shaders Allen White lus. - -## 1. Contexte (résumé exécutif) - -Le passage du player TTS à Unity 6 a strippé les variants Standard receiver des Projectors legacy. Sur Mac, **tous les overlays Projector du mod SWL s'affichent en magenta** : Cohesion Ruler, Range Ruler, Maximum Move, Deployment Boundary. Validation empirique session 14-15 mai : aucun fix côté bundle/shader ne marche (le strip est player-side TTS U6). Fix Berserk = ticket à ouvrir mais sans garantie de calendrier. - -**Solution mod-side validée** : remplacer le pattern Custom_AssetBundle (Projector) par un combo **Decal plat (PNG hébergé) + Vector lines (drapant via raycast)** orchestré par un manager Global event-driven. - -## 1bis. Fallback per-seat avec auto-test au boot - -Le refactor ne **remplace pas** le Projector legacy, il l'**augmente** d'un fallback per-seat. Chaque joueur a un compat flag détecté au boot, et le rendu s'adapte : - -- **Seats non-Mac** (TTS U6 OK) → voient le **Projector legacy** (rendu original préservé) -- **Seats Mac** (bug magenta) → voient le **patch hybride** (decal + vector lines) - -Quand Berserk corrigera le bug, l'auto-test au boot suivant désactivera le compat pour les seats Mac → retour automatique au comportement legacy, zéro action utilisateur. - -### 1bis.1 État par-seat (Global script) - -```lua -compatBySeat = {} -- {Red=true|false, Blue=true|false, ...} true = Mac/needs patch - -function onLoad(script_state) - if script_state ~= "" then - compatBySeat = JSON.decode(script_state) or {} - end - testCompatForAllSeated() -end - -function onSave() - return JSON.encode(compatBySeat) -end - -function getMacSeats() - local out = {} - for color, isMac in pairs(compatBySeat) do - if isMac then table.insert(out, color) end - end - return out -end - -function getNonMacSeats() - local out = {} - for color, isMac in pairs(compatBySeat) do - if not isMac then table.insert(out, color) end - end - return out -end -``` - -### 1bis.2 Test compat au boot - -```lua -function testCompatForAllSeated() - for _, player in pairs(Player.getPlayers()) do - if player.seated then - offerCompatTest(player) - end - end -end - -function offerCompatTest(player) - -- Spawn un canary Projector en zone hors-jeu (sous la table) - local canary = spawnObject({ - type="Custom_AssetBundle", - position={-50, -10, -50}, - scale={0,0,0} - }) - canary.setCustomObject({assetbundle = COHESION_27MM_URL}) - - -- Wait quelques secondes le temps que le bundle charge - Wait.time(function() - -- UI popup pour ce joueur uniquement - player.broadcast("Test rendu : cliquez sur ce que vous voyez sur le canary (Y=-10)") - -- Buttons "Couleurs OK" et "Magenta/rose" - -- onClick stocke compatBySeat[player.color] = true|false - -- destruct canary - end, 3) -end -``` - -### 1bis.3 Spawn d'un overlay (pattern unifié) - -```lua -function spawnOverlay(type, fig, params) - local macSeats = getMacSeats() - local nonMacSeats = getNonMacSeats() - local entry = {type=type, fig=fig, params=params, objects={}} - - -- 1. Projector legacy pour les seats non-Mac - if #nonMacSeats > 0 then - local projector = spawnObject({type="Custom_AssetBundle", ...}) - projector.setCustomObject({assetbundle = bundleURLs[type][fig.baseSize]}) - projector.setLock(true) - projector.setName(type .. "_legacy") - if #macSeats > 0 then projector.setInvisibleTo(macSeats) end - entry.objects.projector = projector - end - - -- 2. Custom_Tile (PNG plat) pour les seats Mac - if #macSeats > 0 then - local tile = spawnObject({type="Custom_Tile", position=fig.pos+Y0.1, scale=...}) - tile.setCustomObject({image = pngURLs[type][fig.baseSize]}) - tile.setLock(true) - tile.setName(type .. "_patch") - if #nonMacSeats > 0 then tile.setInvisibleTo(nonMacSeats) end - entry.objects.tile = tile - end - - -- 3. Vector lines (drape relief) pour les seats Mac - if #macSeats > 0 then - entry.lines = buildVectorLines(type, fig, params, macSeats) - -- ajoutées au batch global - end - - activeOverlays[fig.getGUID() .. ":" .. type] = entry - redrawAll() -end -``` - -### 1bis.4 Bug Berserk fix → reset auto - -Quand Berserk corrigera les variants Standard receiver dans le player TTS U6 : -- Au prochain boot de partie, `testCompatForAllSeated` re-run pour chaque seat -- Les Mac users verront maintenant le canary correctement → cliquent "Couleurs OK" → `compatBySeat[color] = false` -- Le patch ne s'active plus pour eux, comportement legacy restauré -- Aucun changement de code requis côté mod - -**Re-test manuel** : un bouton "Re-test compatibility" dans l'UI Notes du mod permet de forcer un nouveau test à tout moment (utile si le user veut vérifier après une mise à jour TTS). - -### 1bis.5 Incertitudes à valider empiriquement au POC - -| Incertitude | Plan B si KO | -|---|---| -| `Object.setInvisibleTo` cache-t-il le **rendu de la projection** d'un Projector ou seulement le GameObject parent ? | Si KO : si au moins 1 Mac seat présent, on ne spawn PAS le Projector du tout (tout le monde voit le patch — compromis : 1 Mac dans la partie = tout le monde adopte le rendu patch) | -| `Global.setDecals()` supporte-t-il un filtre `players` per entry ? | Si KO : utiliser `Custom_Tile` Objects (déjà ce qu'on a au-dessus, avec `setInvisibleTo`). Plus propre et déjà compatible per-seat | - -À tester en début de phase d'implémentation, avant de pousser tout le code. - -## 2. Architecture event-driven (manager Global) - -### 2.1 État global unique - -```lua --- mod/src/includes/Overlays.ttslua (nouveau) -local activeOverlays = {} -- clé = "{guid}:{type}" → {type, fig, params} -local hiddenWhilePickedUp = {} -- buffer pour les overlays cachés temporairement -``` - -### 2.2 API publique unifiée - -```lua --- Spawn / clear par type, scopé à une fig -function spawnOverlay(type, fig, params) -function clearOverlayForFig(type, fig) -function clearAllOverlays() -- reset complet - --- Adapters pour conserver l'API existante des callers -function spawnCohesionRuler(fig) -- → spawnOverlay("cohesion", fig, {}) -function clearCohesionRuler() -- → clearOverlayForFig("cohesion", self) -function spawnRangeRuler(fig, override) -- → spawnOverlay("range", fig, {override=override}) -function clearRangeRulers() -- → clearOverlayForFig("range", selectedUnitObj) --- etc. -``` - -### 2.3 Event handlers (Global) - -```lua -function onObjectPickUp(player_color, obj) - -- buffer tous les overlays attachés à obj puis les retirer du draw - hidePickedUp(obj) - redrawAll() -end - -function onObjectDrop(player_color, obj) - -- restaurer les overlays de obj depuis le buffer - restorePickedUp(obj) - redrawAll() -end - -function onObjectDestroy(obj) - -- nettoyage définitif - purgeForFig(obj) - redrawAll() -end -``` - -### 2.4 Rendu unique par tick d'event - -```lua -function redrawAll() - local lines, decals = {}, {} - for _, entry in pairs(activeOverlays) do - local l, d = builders[entry.type](entry.fig, entry.params) - for _, line in ipairs(l) do table.insert(lines, line) end - for _, decal in ipairs(d) do table.insert(decals, decal) end - end - Global.setVectorLines(lines) - Global.setDecals(decals) -end -``` - -**Important** : `setVectorLines` et `setDecals` sont des **singletons** côté TTS — chaque appel remplace toute la collection. D'où le manager global qui agrège. - -## 3. Spec par overlay - -### 3.1 Cohesion - -| Composant | Détail | -|---|---| -| Trigger spawn | Hotkey "Show Cohesion On Hovered Model" / bouton COHESION Order Token / dropCoroutine post-mouvement (`moveState=true`) | -| Trigger clear | Mêmes triggers (toggle) + onPickedUp (auto) + standby/reload globaux | -| Decal PNG | `cohesion_halo.png` — gradient radial blanc fade depuis le centre, fond transparent, 512×512px | -| Decal params | `position = fig.pos + Y0.1`, `rotation = (90, 0, 0)` (face vers le haut), `scale = (baseRadius+1, baseRadius+1, baseRadius+1) × 2` | -| Vector line | 1 cercle filaire blanc à `r = base_radius + 0.5"` du centre, drapé par raycast 32 segments | -| Couleur | #FFFFFF α=0.6 | - -API conservée : `showCohesionOnHoveredModel`, `spawnCohesionRuler`, `clearCohesionRuler`. Compatible avec les 3 sites de require existants (Global, Unit_Leader, Order_Token) car redirigés vers le manager global. - -### 3.2 Range Ruler - -| Composant | Détail | -|---|---| -| Trigger spawn | Hotkey "Show Range On Hovered Model" / bouton RANGE Order Token (targetingMode/attackMode) / bouton R sur POI/tokens | -| Trigger clear | Mêmes triggers (toggle) + clearTemplates (post-drop mouvement) + exit modes | -| Decal PNG | `range_bands.png` (universel) — 4 anneaux concentriques à 6/12/18/24 inches relatifs, couleurs jaune/orange/rouge/magenta foncé, fond transparent, 1024×1024px | -| Decal params | `position = fig.pos + Y0.1`, `scale = (60, 60, 60)` (couvre 24" de rayon + marge) | -| Vector lines | 4 cercles filaires aux rayons absolus **6", 12", 18", 24"** du centre, mêmes couleurs que les bandes du PNG | -| Couleur (par anneau) | R1 #FFC300, R2 #FF7500, R3 #FF1400, R4 #C20042 (alpha 0.6 sur les lignes) | -| Variantes | Tokens (smokeToken/token/tokenRangeTwo/bombCart/POI) → moins d'anneaux (1 ou 2), PNG distincts ou même PNG avec scale ajusté | - -API conservée : `showRangeOnHoveredModel`, `spawnRangeRuler(fig, override)`, `clearRangeRuler/clearRangeRulers`. - -Pour les variantes, le `override` permet de passer un type explicite (`"smokeToken"`, `"poi"`, etc.) au lieu du baseSize de la fig. - -### 3.3 Maximum Move - -| Composant | Détail | -|---|---| -| Trigger spawn | Lors du clic sur bouton speed (1/2/3) sur Order Token (flow mouvement) | -| Trigger clear | clearMovementTemplates (drop final) | -| Decal PNG | `max_move_disk.png` — disque plein bleu ciel #55CCFF α=0.48, fond transparent, 512×512px | -| Decal params | `position = fig.pos + Y0.1`, `scale = ProjectorRadius × 2` selon (baseSize, speed) | -| Vector line | 1 cercle filaire bleu ciel au rayon = ProjectorRadius (drape relief) + 1 anneau base blanc fin à r = baseRadius + 0.5" | -| Couleur | Cercle principal #55CCFF α=0.6, anneau base #FFFFFF α=0.5 | -| Rayons | Lookup table `maxMoveRadius[baseSize][speed]` (ex: `27mm.speed1=4.55", speed2=6.52", speed3=8.48"`) — extraits de `_ProjectorRadius` dans les materials Movement | - -API conservée : flow existant dans `Order_Token.a57c41.lua:552-574`, remplacement du `spawnObject Custom_AssetBundle` par `spawnOverlay("maxmove", fig, {speed=selectedSpeed, baseSize=unitData.baseSize})`. - -### 3.4 Deployment Boundary - -| Composant | Détail | -|---|---| -| Trigger spawn | `spawnDeploymentBoundary(matrix)` au setup (depuis menu UI Deployment) | -| Trigger clear | `clearDeploymentBoundary()` (menu UI Remove Overlay) + standby global | -| Decal PNG | `deployment_red.png` + `deployment_blue.png` — rectangle plein uniforme α=0.5, 256×256px | -| Decal params | Par cellule de la matrix, `position = grid pos + offset`, `scale = (6, 6, 6)` (taille standard d'une cellule SWL), rotation selon `deployRotations[cell]` | -| Vector lines | Contour rectangle drapant (4 côtés × N segments via raycast) par cellule, couleur match decal | -| Couleur | Rouge #FF0000 / Bleu #0000FF α=0.5 | -| Variantes | Half/L/Corner/Round → combinaisons de cellules dans la matrix, le manager gère chaque cellule individuellement (1 decal + 1 contour par cellule) | - -API conservée : `spawnDeploymentBoundary(matrix)`, `clearDeploymentBoundary()`. Internement, chaque cellule est enregistrée comme un overlay distinct avec un GUID synthétique (ex `"deployment:bs:5:3"`). - -## 4. Assets PNG à générer - -| Fichier | Dimensions | Description | Hosting | -|---|---|---|---| -| `cohesion_halo.png` | 512×512 | Gradient radial blanc centre → transparent bord, RGBA | iron-squadron.fr/tts-assets/ | -| `range_bands.png` | 1024×1024 | 4 anneaux concentriques aux rayons relatifs 0.25/0.5/0.75/1.0, gradient discret (pas blend), couleurs jaune/orange/rouge/magenta, alpha 0.6 | iron-squadron.fr/tts-assets/ | -| `range_smoke.png` | 256×256 | 1 anneau à rayon relatif 1.0, blanc opaque | idem | -| `range_token.png` | 256×256 | 1 anneau à rayon relatif 1.0, jaune | idem | -| `range_tokenRangeTwo.png` | 256×256 | 2 anneaux, jaune + orange | idem | -| `range_poi.png` | 256×256 | 1 anneau pour POI (range 0.5 / 3") | idem | -| `range_bombCart.png` | 256×256 | 2 anneaux | idem | -| `max_move_disk.png` | 512×512 | Disque plein bleu ciel #55CCFF α=0.48, RGBA | idem | -| `deployment_red.png` | 256×256 | Carré rouge plein #FF0000 α=0.5 | idem | -| `deployment_blue.png` | 256×256 | Carré bleu plein #0000FF α=0.5 | idem | - -**Génération** : script Python Pillow `generate_overlay_assets.py` qui produit tous les PNG d'un coup. Reproductible, versionnable dans `swlegion-tts/tool/`. - -**Upload** : `rsync` vers le VPS Iron Squadron, dossier `/var/www/iron-squadron/tts-assets/` (ou similaire). URL stable indépendante de Steam. - -## 5. Edge cases et invariants - -| Cas | Gestion | -|---|---| -| Multi-overlays simultanés (plusieurs figs avec cohesion + range) | Cohabitent dans la table `activeOverlays`, un `redrawAll()` au moindre changement | -| Fig détruite (mort en combat) | `onObjectDestroy` → `purgeForFig` → redraw | -| Pickup pendant overlay actif | `onObjectPickUp` → buffer dans `hiddenWhilePickedUp`, retire du rendu | -| Drop après pickup | `onObjectDrop` → restore depuis buffer, redraw (positions recalculées par les builders) | -| Pickup d'un objet sans overlay | Handler skip silencieux (test sur présence dans la table) | -| Reload de partie | `activeOverlays` se reset au boot (variable Lua locale du Global), OK | -| Filtres `standbyTokens` / `removeLockedRulers` (scans `getAllObjects()` par nom) | Deviennent caducs (plus d'Object physique). À supprimer ou laisser comme no-op | -| Test multi-joueurs réseau | Le manager Global est synchronisé par TTS (Lua sync), donc tous les joueurs voient les mêmes overlays | -| Drape impossible sur certains terrains custom | Limitation acceptable (déjà flag par Ben). Le decal plat sert de fallback visuel | - -## 6. Chemin de migration (séquentiel, testable étape par étape) - -1. **Setup Manager Global** : créer `mod/src/includes/Overlays.ttslua`. Aucun caller encore branché. Test : `npm run compile && load mod`, no-op confirmé -2. **Cohesion** (60 lignes, le plus simple) : réécrire `mod/src/includes/Cohesion.ttslua` pour rediriger vers le manager. Test : spawn via hotkey + bouton + dropCoroutine sur Mac -3. **Range Ruler** : réécrire `mod/src/includes/RangeRulers.ttslua` + adapter `POI_Token.lua` + `TokenWithRangeRuler.ttslua`. Test : 4 anneaux + tokens -4. **Maximum Move** : adapter `Order_Token.a57c41.lua:552-574` (spawn inline → call manager) + `clearMovementTemplates`. Test : flow mouvement complet sur Mac -5. **Deployment** : réécrire `SETUP_CONTROLLER.1cb552.lua:243-323`. Test : sélection scenario + spawn de la zone -6. **Cleanup** : supprimer les filtres `standbyTokens` / `removeLockedRulers` pour les types qui ne sont plus des Objects. Garder le filtre pour Movement Template (mesh, toujours Object) -7. **PR sur swlegion/tts** : branche `mac-overlays-refactor`, description du bug + solution + diff. Test communauté - -## 7. Compatibilité conservée - -- **Tous les sites de require existants** continuent à fonctionner sans modif (`require('!/Cohesion')`, `require('!/RangeRulers')`) -- **Toutes les fonctions globales conservent leur signature** (`spawnCohesionRuler(fig)`, `clearRangeRulers()`, etc.) -- **Pas de change d'API publique** → callers (Unit_Leader, Order_Token, POI_Token, etc.) inchangés -- **Variables d'état globales** comme `selectedUnitObj` conservées pour compat avec les filtres et la logique de toggle - -## 8. Risques connus à surveiller - -| Risque | Mitigation | -|---|---| -| Performance multi-overlays (N décals + N×64 vectors) | Tester empiriquement sur table chargée. Si lag, sparse raycast (32 segments) ou skip raycast si table plate détectée | -| Synchronisation decal/vector lines pas pixel-perfect | Vector lines au-dessus du decal (Y légèrement supérieur), tolérance visuelle | -| URL Iron Squadron offline | Hosting sur 2 destinations (VPS + GitHub Pages backup) ou inclure le CDN dans le repo si TTS supporte les data URIs | -| Bug U6 sur les decals aussi (improbable, POC validé le 14 mai) | Garder Path B = ticket Berserk en parallèle | -| Refactor casse un caller obscur | Test exhaustif des 5 sites de spawn + tous les triggers connus | - -## 9. Annexes - -- `materials-reference.md` : tous les params visuels extraits -- `retro-cohesion.md`, `retro-range.md`, `retro-deployment.md`, `retro-movement.md` : déclencheurs et call graphs -- `swlegion-tts/mod/src/includes/` : code source des modules à patcher diff --git a/mac-patcher/extract_projector_specs.py b/mac-patcher/extract_projector_specs.py deleted file mode 100644 index e699f8f16..000000000 --- a/mac-patcher/extract_projector_specs.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -Scan Unity .mat and .prefab YAML files in UnityProject-U6/Assets/Projectors/ -and extract critical parameters for the SWL TTS mod refactor. - -Output: materials-reference.md with structured tables per overlay. -""" -import os -import re -from pathlib import Path -from collections import defaultdict - -ROOT = Path("/Users/martinpourrat/MARTIN/Star Wars Legion/Mod TTS SWL/UnityProject-U6/Assets/Projectors") -OUT = Path("/Users/martinpourrat/MARTIN/Star Wars Legion/Mod TTS SWL/materials-reference.md") - -OVERLAYS = ["Cohesion", "Range", "Deployment", "Movement"] - - -def read(p: Path) -> str: - return p.read_text(encoding="utf-8", errors="replace") - - -def parse_material(path: Path) -> dict: - """Extract critical fields from a Unity .mat YAML file.""" - text = read(path) - out = {"path": str(path.relative_to(ROOT)), "name": path.stem} - - m = re.search(r"m_ShaderKeywords:\s*(.*)", text) - out["keywords"] = m.group(1).strip() if m else "" - - m = re.search(r"m_Shader:\s*\{fileID:\s*\d+,\s*guid:\s*([0-9a-f]+)", text) - out["shader_guid"] = m.group(1) if m else "" - - # Floats — capture key params - floats = {} - for fname in ["_BaseSize", "_ProjectorRadius", "_BandSize", "_BandContrast", - "_MaxRange", "_Arc", "_RangeSize", "_GradScaler", - "_OneTuner", "_TwoTuner", "_ThreeTuner", "_FourTuner", - "_FiveTuner", "_InfinityTuner"]: - m = re.search(rf"-\s*{re.escape(fname)}:\s*(-?[\d.]+)", text) - if m: - floats[fname] = m.group(1) - out["floats"] = floats - - # Colors — capture all _Color* / _Range* - colors = {} - for m in re.finditer(r"-\s*(_\w+):\s*\{r:\s*([\d.]+),\s*g:\s*([\d.]+),\s*b:\s*([\d.]+),\s*a:\s*([\d.]+)\}", text): - name, r, g, b, a = m.groups() - if name in ("_BumpScale",): # not a color - continue - colors[name] = (float(r), float(g), float(b), float(a)) - out["colors"] = colors - - return out - - -def parse_prefab(path: Path) -> dict: - """Extract critical fields from a Unity .prefab YAML file (Projector components).""" - text = read(path) - out = {"path": str(path.relative_to(ROOT)), "name": path.stem} - - # Count Projector components (Unity type id 119) - projectors = re.findall(r"---\s*!u!119\s*&\d+\s*\nProjector:(.*?)(?=\n---|\Z)", text, re.DOTALL) - out["n_projectors"] = len(projectors) - - proj_data = [] - for body in projectors: - d = {} - for fname in ["m_OrthographicSize", "m_FarClipPlane", "m_NearClipPlane", - "m_FieldOfView", "m_AspectRatio", "m_Orthographic"]: - m = re.search(rf"{fname}:\s*(-?[\d.]+)", body) - if m: - d[fname] = m.group(1) - m = re.search(r"m_Material:\s*\{fileID:\s*\d+,\s*guid:\s*([0-9a-f]+)", body) - if m: - d["material_guid"] = m.group(1) - proj_data.append(d) - out["projectors"] = proj_data - - # Transforms (LocalPosition Y, rotation) - transforms = [] - for body in re.findall(r"---\s*!u!4\s*&\d+\s*\nTransform:(.*?)(?=\n---|\Z)", text, re.DOTALL): - d = {} - m = re.search(r"m_LocalPosition:\s*\{x:\s*(-?[\d.]+),\s*y:\s*(-?[\d.]+),\s*z:\s*(-?[\d.]+)\}", body) - if m: - d["pos"] = (float(m.group(1)), float(m.group(2)), float(m.group(3))) - m = re.search(r"m_LocalEulerAnglesHint:\s*\{x:\s*(-?[\d.]+),\s*y:\s*(-?[\d.]+),\s*z:\s*(-?[\d.]+)\}", body) - if m: - d["rot"] = (float(m.group(1)), float(m.group(2)), float(m.group(3))) - transforms.append(d) - out["transforms"] = transforms - - return out - - -def rgba_to_hex(r, g, b, a): - """Convert 0-1 RGBA to hex + alpha note.""" - rh, gh, bh = int(r * 255), int(g * 255), int(b * 255) - return f"#{rh:02X}{gh:02X}{bh:02X} (α={a:.2f})" - - -def fmt_colors(colors): - """Format dict of colors as compact markdown.""" - if not colors: - return "—" - parts = [] - for name, (r, g, b, a) in colors.items(): - if a == 0 and r == 0 and g == 0 and b == 0: - parts.append(f"`{name}` transparent") - else: - parts.append(f"`{name}` {rgba_to_hex(r, g, b, a)}") - return "
".join(parts) - - -def fmt_floats(floats): - """Format dict of floats.""" - if not floats: - return "—" - return ", ".join(f"`{k}`={v}" for k, v in floats.items()) - - -def main(): - materials_by_overlay = defaultdict(list) - prefabs_by_overlay = defaultdict(list) - - for overlay in OVERLAYS: - for p in sorted((ROOT / overlay).rglob("*.mat")): - if p.suffix == ".meta": - continue - materials_by_overlay[overlay].append(parse_material(p)) - for p in sorted((ROOT / overlay).rglob("*.prefab")): - if p.suffix == ".meta": - continue - prefabs_by_overlay[overlay].append(parse_prefab(p)) - - # Build the master doc - lines = [] - lines.append("# Materials & Prefabs Reference — SWL TTS Projector Overlays") - lines.append("") - lines.append("Extraction directe des `.mat` et `.prefab` Unity (YAML) dans `UnityProject-U6/Assets/Projectors/`. Source de vérité pour reproduire le rendu en vector lines.") - lines.append("") - lines.append("Notation : couleurs en hex + alpha. Champs `Floats` clés du shader projector (paramètres procéduraux des anneaux).") - lines.append("") - - for overlay in OVERLAYS: - lines.append(f"## {overlay}") - lines.append("") - - # Materials table - lines.append("### Materials") - lines.append("") - lines.append("| Material | Shader keywords | Floats clés | Couleurs |") - lines.append("|---|---|---|---|") - for m in materials_by_overlay[overlay]: - kw = m["keywords"] or "—" - lines.append(f"| `{m['name']}` | `{kw}` | {fmt_floats(m['floats'])} | {fmt_colors(m['colors'])} |") - lines.append("") - - # Prefabs table - lines.append("### Prefabs") - lines.append("") - lines.append("| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid |") - lines.append("|---|---|---|---|---|") - for p in prefabs_by_overlay[overlay]: - n = p["n_projectors"] - proj_sizes = [pr.get("m_OrthographicSize", "?") for pr in p["projectors"]] - proj_far = [pr.get("m_FarClipPlane", "?") for pr in p["projectors"]] - proj_summary = "
".join(f"size={s}, far={f}" for s, f in zip(proj_sizes, proj_far)) if proj_sizes else "—" - - tr_summary = "
".join( - f"pos=({t.get('pos', ('?',)*3)[0]}, {t.get('pos', ('?',)*3)[1]}, {t.get('pos', ('?',)*3)[2]}) rot=({t.get('rot', ('?',)*3)[0]}, {t.get('rot', ('?',)*3)[1]}, {t.get('rot', ('?',)*3)[2]})" - for t in p["transforms"] - ) if p["transforms"] else "—" - - mat_guids = [pr.get("material_guid", "?") for pr in p["projectors"]] - mat_summary = "
".join(g[:12] + "…" for g in mat_guids) if mat_guids else "—" - - lines.append(f"| `{p['name']}` | {n} | {proj_summary} | {tr_summary} | {mat_summary} |") - lines.append("") - - # Final notes - lines.append("## Notes globales") - lines.append("") - lines.append("- Cohesion : double set `Materials/cohesion_*` et `_Revamp/halfCohesion_*`. Probable : `_Revamp/` est la version actuellement utilisée (le bundle live s'appelle `halfcohesion_27mm.unity3d` cf shader-inventory).") - lines.append("- Range : shader procédural avec jusqu'à 5 bandes (`_RangeOne` à `_RangeFive`) + `_RangeInfinity`. Le keyword `_MAXRANGE_RANGEFIVE` ou `_MAXRANGE_RANGEFOUR` détermine la dernière bande visible.") - lines.append("- Movement : un seul Projector par prefab, paramétré par `_BaseSize` + speed. Le `_Arc` doit contrôler la portion d'arc visible.") - lines.append("- Deployment : 6 materials uniques réutilisés par 12 prefabs (rotations différentes pour matérialiser corner/half/L/round/score).") - - OUT.write_text("\n".join(lines), encoding="utf-8") - print(f"Wrote {OUT}") - # Stats - total_mats = sum(len(v) for v in materials_by_overlay.values()) - total_prefabs = sum(len(v) for v in prefabs_by_overlay.values()) - print(f"Scanned {total_mats} materials and {total_prefabs} prefabs across {len(OVERLAYS)} overlays.") - - -if __name__ == "__main__": - main() diff --git a/mac-patcher/generate_overlay_assets.py b/mac-patcher/generate_overlay_assets.py deleted file mode 100644 index d11b19321..000000000 --- a/mac-patcher/generate_overlay_assets.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate PNG assets for the Mac Cohesion + Range overlay fallback. -""" -from pathlib import Path -from PIL import Image, ImageDraw -import math - -OUT_DIR = Path(__file__).parent / "tts-assets" -OUT_DIR.mkdir(exist_ok=True) - - -# Range band colors (R, G, B in 0..255) — from BB_RangeProjector defaults -RANGE_COLORS = { - "half": (255, 255, 255), # 0.5 / 3" — white - "one": (255, 195, 0), # R1 / 6" — yellow - "two": (255, 117, 0), # R2 / 12" — orange - "three": (255, 20, 0), # R3 / 18" — red - "four": (195, 0, 66), # R4 / 24" — magenta - "five": (134, 0, 112), # R5 / 30" — violet -} - -# Configurations per overlay type (bands = list of (radius_inches, color_key)) -# Edge-based convention: each band's effective world radius is band_r + base_r. -RANGE_BAND_CONFIGS = { - "fig_leader": { - "bands": [(3, "half"), (6, "one"), (12, "two"), - (18, "three"), (24, "four"), (30, "five")], - "max_radius": 30, - }, - # SWL ranges in inches: Range 1 = 6", Range 2 = 12", Range 0.5 (poi) = 3". - "smokeToken": {"bands": [(6, "one")], "max_radius": 6}, - "token": {"bands": [(6, "one")], "max_radius": 6}, - "tokenRangeTwo": {"bands": [(6, "one"), (12, "two")], "max_radius": 12}, - "poi": {"bands": [(3, "one")], "max_radius": 3}, - "bombCart": {"bands": [(6, "one"), (12, "two")], "max_radius": 12}, -} - -# Base radii (inches, half-diameter) per ROUND fig base size. Oblong bases -# (long, snail) live in OBLONG_DIMENSIONS_MM below — they don't use a single -# radius and don't generate round PNGs. -FIG_BASE_RADIUS_IN = { - "small": 27 / 2 / 25.4, - "medium": 50 / 2 / 25.4, - "large": 70 / 2 / 25.4, - "huge": 100 / 2 / 25.4, - "laat": 120 / 2 / 25.4, - "epic": 150 / 2 / 25.4, -} - -# Token base radii (inches), only needed for multi-band tokens. -TOKEN_BASE_RADIUS_IN = { - "tokenRangeTwo": 25.1 / 2 / 25.4, - "bombCart": 50.0 / 2 / 25.4, -} - - -def _with_base_offset(config: dict, base_r: float) -> dict: - """Shift band radii outward by base_r and grow max_radius accordingly so - the PNG band proportions match the world positions of the vector-line - rings (which are drawn at band_r + base_r).""" - return { - "bands": [(r + base_r, c) for r, c in config["bands"]], - "max_radius": config["max_radius"] + base_r, - } - - -def generate_cohesion_halo(size: int = 512) -> Path: - """Halo radial blanc fade transparent — reproduit le rendu Cohesion bundle. - - Le shader BB_CohesionProjector fait `rangeOne = (1 - grad)` puis multiplie - par alpha global. On reproduit ce fade : opaque au centre, transparent au - bord. - """ - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - pixels = img.load() - cx, cy = size / 2, size / 2 - max_r = size / 2 - - # Alpha global du material Cohesion 27mm = 0.37 ; on multiplie par le fade - base_alpha = 0.37 - - for y in range(size): - for x in range(size): - dx, dy = x - cx, y - cy - dist = math.sqrt(dx * dx + dy * dy) - r_norm = dist / max_r # [0, 1+] depuis le centre - if r_norm > 1.0: - continue # transparent au-delà du rayon - - # Fade radial : (1 - r) avec courbe douce - # Le shader fait (1 - grad) linéaire, on garde la même chose - fade = max(0.0, 1.0 - r_norm) - alpha = int(base_alpha * fade * 255) - pixels[x, y] = (255, 255, 255, alpha) - - out = OUT_DIR / "cohesion_halo.png" - img.save(out) - return out - - -def generate_range_band_png(name: str, config: dict, size: int = 1024, - alpha: float = 0.2) -> Path: - """Generate a circular range overlay with concentric bands. - - Drawing from outer-to-inner: each filled ellipse overlays the previous, - producing clean rings. - """ - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - cx = cy = size / 2 - max_r_pix = size / 2 - a = int(alpha * 255) - - for band_r, color_key in reversed(config["bands"]): - r_pix = (band_r / config["max_radius"]) * max_r_pix - r, g, b = RANGE_COLORS[color_key] - draw.ellipse( - [(cx - r_pix, cy - r_pix), (cx + r_pix, cy + r_pix)], - fill=(r, g, b, a), - ) - - out = OUT_DIR / f"range_{name}.png" - img.save(out) - return out - - -# Oblong base dimensions (mm). Used to render stadium-shape range overlays -# whose 6 bands stay concentric to a rectangular footprint with rounded caps. -OBLONG_DIMENSIONS_MM = { - "long": {"width": 100, "length": 175}, - "snail": {"width": 100, "length": 200}, -} - - -def generate_range_band_stadium_png(base_size: str, dims_mm: dict, - config: dict, alpha: float = 0.2, - pixels_per_inch: int = 16) -> Path: - """Generate a stadium-shape PNG with 6 concentric range bands. - - The PNG dimensions encode the *outermost* band's bounding box (so the - decal can be stretched in TTS using scale = {shortSpan, longSpan, 1} and - the bands stay aligned with the matching vector stadium outlines). - - A stadium = rounded rectangle whose corner radius equals half the - short dimension, producing a pill shape with semicircular caps on the - long-axis ends. Each band is one rounded_rectangle of width - 2*(halfWid + band_r) and length 2*(halfLen + band_r), corner radius - halfWid + band_r. Drawn outer-to-inner so each band overpaints the next. - """ - halfWid_in = (dims_mm["width"] / 2) / 25.4 - halfLen_in = (dims_mm["length"] / 2) / 25.4 - max_dist = config["max_radius"] - - shortSpan_in = 2 * (halfWid_in + max_dist) - longSpan_in = 2 * (halfLen_in + max_dist) - - W = int(round(shortSpan_in * pixels_per_inch)) - H = int(round(longSpan_in * pixels_per_inch)) - cx, cy = W / 2, H / 2 - - img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - a = int(alpha * 255) - - for band_r, color_key in reversed(config["bands"]): - half_w_px = (halfWid_in + band_r) * pixels_per_inch - half_h_px = (halfLen_in + band_r) * pixels_per_inch - r, g, b = RANGE_COLORS[color_key] - draw.rounded_rectangle( - [(cx - half_w_px, cy - half_h_px), - (cx + half_w_px, cy + half_h_px)], - radius=half_w_px, # = half of the short side → semicircle caps - fill=(r, g, b, a), - ) - - out = OUT_DIR / f"range_fig_leader_{base_size}_stadium.png" - img.save(out) - return out - - -def generate_filled_disk(name: str, color_rgb: tuple, size: int = 512, - alpha: float = 0.2) -> Path: - """Generate a simple filled disk PNG (used for Maximum Move overlay).""" - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - cx = cy = size / 2 - r = size / 2 - a = int(alpha * 255) - draw.ellipse([(cx - r, cy - r), (cx + r, cy + r)], - fill=(color_rgb[0], color_rgb[1], color_rgb[2], a)) - out = OUT_DIR / f"{name}.png" - img.save(out) - return out - - -def generate_filled_square(name: str, color_rgb: tuple, size: int = 256, - alpha: float = 0.3) -> Path: - """Generate a filled square PNG (used for Deployment Boundary cells).""" - img = Image.new("RGBA", (size, size), - (color_rgb[0], color_rgb[1], color_rgb[2], int(alpha * 255))) - out = OUT_DIR / f"{name}.png" - img.save(out) - return out - - -def generate_silhouette_top_disc(size: int = 512) -> Path: - """Hologram-style top cap for the Mac silhouette cylinder. - - Radial gradient: bright green-cyan center fades to transparent at the - rim. Combined with the vector wireframe cylinder this gives the - Star Wars hologram briefing look (Leia "Help me Obi-Wan" projection). - """ - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - pixels = img.load() - cx, cy = size / 2, size / 2 - max_r = size / 2 - - r_center, g_center, b_center = 102, 255, 178 # bright hologram cyan-green - a_center = 0.85 - - for y in range(size): - for x in range(size): - dx, dy = x - cx, y - cy - dist = math.sqrt(dx * dx + dy * dy) - r_norm = dist / max_r - if r_norm > 1.0: - continue - # Smooth radial falloff (1-r squared) for a soft hologram glow - fade = (1.0 - r_norm) ** 1.5 - alpha = int(a_center * fade * 255) - pixels[x, y] = (r_center, g_center, b_center, alpha) - - out = OUT_DIR / "silhouette_top.png" - img.save(out) - return out - - -def main(): - p = generate_cohesion_halo() - print(f"Generated {p} ({p.stat().st_size} bytes)") - - # Fig leaders (round bases): one PNG per base size, bands shifted by base radius. - fig_cfg = RANGE_BAND_CONFIGS["fig_leader"] - for base_size, br in FIG_BASE_RADIUS_IN.items(): - out = generate_range_band_png(f"fig_leader_{base_size}", - _with_base_offset(fig_cfg, br)) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Oblong bases: stadium-shape PNGs (rectangular footprint + rounded caps). - for base_size, dims in OBLONG_DIMENSIONS_MM.items(): - out = generate_range_band_stadium_png(base_size, dims, fig_cfg) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Multi-band tokens: bake in their base radius for alignment. - for name, br in TOKEN_BASE_RADIUS_IN.items(): - cfg = RANGE_BAND_CONFIGS[name] - out = generate_range_band_png(name, _with_base_offset(cfg, br)) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Single-band tokens: no offset adjustment needed (band sits at outer edge). - for name in ("smokeToken", "token", "poi"): - out = generate_range_band_png(name, RANGE_BAND_CONFIGS[name]) - print(f"Generated {out} ({out.stat().st_size} bytes)") - - # Maximum Move: cyan filled disk (#55CCFF) - p = generate_filled_disk("max_move_cyan", (85, 204, 255), alpha=0.2) - print(f"Generated {p} ({p.stat().st_size} bytes)") - - # Deployment Boundary: red and blue filled squares (alpha 0.5 = match bundle) - p = generate_filled_square("deployment_red", (255, 0, 0), alpha=0.3) - print(f"Generated {p} ({p.stat().st_size} bytes)") - p = generate_filled_square("deployment_blue", (0, 0, 255), alpha=0.3) - print(f"Generated {p} ({p.stat().st_size} bytes)") - - # Silhouette top cap: hologram-style green/cyan radial gradient. - p = generate_silhouette_top_disc() - print(f"Generated {p} ({p.stat().st_size} bytes)") - - -if __name__ == "__main__": - main() diff --git a/mac-patcher/inspect_cohesion.py b/mac-patcher/inspect_cohesion.py deleted file mode 100644 index e26becbed..000000000 --- a/mac-patcher/inspect_cohesion.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Inspect Cohesion bundle structure: list objects, materials, shaders, and the m_Shader PPtr.""" -import os -import sys -import UnityPy - -BUNDLE = os.path.expanduser( - "~/Library/Tabletop Simulator/Mods/Assetbundles/" - "httpssteamusercontentaakamaihdnetugc2482129948496305632EBBE2560D4336E6C96317EDDA787E225CF0E5B48.unity3d" -) - -env = UnityPy.load(BUNDLE) - -print("=" * 80) -print("BUNDLE METADATA") -print("=" * 80) -for f in env.files.values(): - print(f" file: {type(f).__name__}") - if hasattr(f, "unity_version"): - print(f" unity_version: {f.unity_version}") - if hasattr(f, "target_platform"): - print(f" target_platform: {f.target_platform}") - if hasattr(f, "externals"): - print(f" externals (cross-bundle refs):") - for ext in getattr(f, "externals", []): - print(f" - {ext}") - -print() -print("=" * 80) -print("ALL OBJECTS") -print("=" * 80) -for obj in env.objects: - path_id = getattr(obj, "path_id", "?") - type_name = obj.type.name - try: - data = obj.read() - name = getattr(data, "m_Name", "") or "" - except Exception as e: - name = f"" - print(f" PathID={path_id:<6} type={type_name:<15} name={name!r}") - -print() -print("=" * 80) -print("MATERIAL DETAILS (looking for Cohesion_27mm)") -print("=" * 80) -for obj in env.objects: - if obj.type.name != "Material": - continue - data = obj.read() - print(f"\nMaterial PathID={obj.path_id} name={data.m_Name!r}") - sref = data.m_Shader - print(f" m_Shader PPtr:") - print(f" file_id = {getattr(sref, 'file_id', '?')} " - f"(0 = internal to this bundle, !=0 = external file)") - print(f" path_id = {getattr(sref, 'path_id', '?')}") - # Try to resolve - try: - sobj = sref.deref() - if sobj: - sd = sobj.read() - sname = ( - getattr(getattr(sd, "m_ParsedForm", None), "m_Name", None) - or getattr(sd, "m_Name", None) - ) - print(f" resolved -> Shader name = {sname!r}") - else: - print(f" resolved -> None (external or missing)") - except Exception as e: - print(f" resolve error: {e!r}") - -print() -print("=" * 80) -print("SHADER DETAILS") -print("=" * 80) -for obj in env.objects: - if obj.type.name != "Shader": - continue - data = obj.read() - sname = ( - getattr(getattr(data, "m_ParsedForm", None), "m_Name", None) - or getattr(data, "m_Name", None) - ) - print(f" PathID={obj.path_id} Shader name={sname!r}") diff --git a/mac-patcher/inspect_externals.py b/mac-patcher/inspect_externals.py deleted file mode 100644 index c9307a210..000000000 --- a/mac-patcher/inspect_externals.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -"""For each bundle: dump its externals (referenced external files) and list materials with external m_Shader PPtr.""" -import os -import sys -import UnityPy - -BUNDLE_DIR = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles/") - -bundles = sorted(f for f in os.listdir(BUNDLE_DIR) if f.endswith(".unity3d")) - -# We are most interested in bundles whose materials use external shaders (file_id != 0) -print("=" * 90) -print("BUNDLES WITH EXTERNAL m_Shader REFERENCES") -print("=" * 90) - -for fname in bundles: - path = os.path.join(BUNDLE_DIR, fname) - short_id = fname.replace("httpssteamusercontentaakamaihdnetugc", "")[:20] - env = UnityPy.load(path) - - external_mats = [] - for obj in env.objects: - if obj.type.name != "Material": - continue - try: - data = obj.read() - sref = data.m_Shader - fid = getattr(sref, "file_id", None) - pid = getattr(sref, "path_id", None) - if fid and fid != 0: - external_mats.append((data.m_Name, fid, pid)) - except Exception: - pass - - if not external_mats: - continue - - print(f"\n{short_id}:") - # Dump externals table - for f in env.files.values(): - if hasattr(f, "externals"): - print(f" externals table:") - for i, ext in enumerate(getattr(f, "externals", [])): - # The externals entry usually has a 'path' and 'guid' - ext_path = getattr(ext, "path", "?") - ext_guid = getattr(ext, "guid", "?") - # index i+1 because file_id is 1-based for externals (file_id=0 means self) - print(f" file_id={i+1} path={ext_path!r} guid={ext_guid}") - print(f" materials with external m_Shader:") - for name, fid, pid in external_mats: - print(f" material={name!r} m_Shader=(file_id={fid}, path_id={pid})") diff --git a/mac-patcher/materials-reference.md b/mac-patcher/materials-reference.md deleted file mode 100644 index eabaae364..000000000 --- a/mac-patcher/materials-reference.md +++ /dev/null @@ -1,170 +0,0 @@ -# Materials & Prefabs Reference — SWL TTS Projector Overlays - -Extraction directe des `.mat` et `.prefab` Unity (YAML) dans `UnityProject-U6/Assets/Projectors/`. Source de vérité pour reproduire le rendu en vector lines. - -Notation : couleurs en hex + alpha. Champs `Floats` clés du shader projector (paramètres procéduraux des anneaux). - -## Cohesion - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `Cohesion_27mm` | `_ARC_OFF _MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=4.479488, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.37)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #000000 (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_50mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=5.470629, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_70mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=6.850394, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_27mm` | `_ARC_OFF _MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=3.531496, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.37)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #000000 (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_50mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=3.984252, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | -| `Cohesion_70mm` | `_MAXRANGE_RANGEONE _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=4.3779526, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.67)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #FFFFFF (α=0.30)
`_MoveTwo` #FF0000 (α=1.00)
`_RangeFive` #FFFF00 (α=1.00)
`_RangeFour` #00FF00 (α=1.00)
`_RangeInfinity` #7F7F7F (α=1.00)
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF6C00 (α=1.00)
`_RangeTwo` #0000FF (α=1.00) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `cohesion_27mm` | 1 | size=4.547244, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | ebe2e870a59f… | -| `cohesion_50mm` | 1 | size=5.669291, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 406845c49cf4… | -| `cohesion_70mm` | 1 | size=6.850394, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 3a57b0b27e32… | -| `halfCohesion_27mm` | 1 | size=3.531496, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 0e1c90ce1ef6… | -| `halfCohesion_50mm` | 1 | size=3.984252, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 2e05a24ab516… | -| `halfCohesion_70mm` | 1 | size=4.3779526, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 7cee44cc736f… | - -## Range - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `ProjectorMaterial_100mm` | `_ARC_ON _MAXRANGE_RANGEFIVE _RANGE_FIVE` | `_BaseSize`=100, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.0183, `_TwoTuner`=0.0062, `_ThreeTuner`=0.0245, `_FourTuner`=0.0123, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_100mm_oblong` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=47.4, `_ProjectorRadius`=30, `_BandSize`=0.34, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=-0.022, `_TwoTuner`=-0.0421, `_ThreeTuner`=-0.0324, `_FourTuner`=-0.0525, `_FiveTuner`=0.073, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_100mm_snail` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=47.4, `_ProjectorRadius`=30, `_BandSize`=0.34, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=-0.0216, `_TwoTuner`=-0.0421, `_ThreeTuner`=-0.0324, `_FourTuner`=-0.0525, `_FiveTuner`=0.073, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_120mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=120, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.022, `_TwoTuner`=0.0074, `_ThreeTuner`=0.0292, `_FourTuner`=0.0146, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_150mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=150, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.027, `_TwoTuner`=0.009, `_ThreeTuner`=0.0358, `_FourTuner`=0.018, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_18mm_smokeToken` | `_ARC_OFF _MAXRANGE_RANGEONE` | `_BaseSize`=18.8, `_ProjectorRadius`=6, `_BandSize`=-0.11, `_BandContrast`=0.46, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #3A3145 (α=0.59)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFFFFF (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_25mm_token` | `_ARC_OFF _MAXRANGE_RANGEONE` | `_BaseSize`=27, `_ProjectorRadius`=2.97, `_BandSize`=-0.45, `_BandContrast`=3, `_MaxRange`=0, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_25mm_tokenRangeTwo` | `_ARC_OFF _MAXRANGE_RANGETWO` | `_BaseSize`=28.12, `_ProjectorRadius`=14.9, `_BandSize`=0.04, `_BandContrast`=3, `_MaxRange`=1, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0062, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_27mm` | `_ARC_OFF _MAXRANGE_RANGEFIVE` | `_BaseSize`=27, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=0, `_RangeSize`=-76.5, `_GradScaler`=0.0034, `_OneTuner`=0.0053, `_TwoTuner`=0.0019, `_ThreeTuner`=0.0072, `_FourTuner`=0.0037, `_FiveTuner`=0, `_InfinityTuner`=0.0034 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_50mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=50, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=3, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_GradScaler`=0, `_OneTuner`=0.0098, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0127, `_FourTuner`=0.0063, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_50mm_bombCart` | `_ARC_OFF _MAXRANGE_RANGETWO` | `_BaseSize`=50, `_ProjectorRadius`=12, `_BandSize`=0.01, `_BandContrast`=3, `_MaxRange`=1, `_Arc`=0, `_RangeSize`=-76.5, `_OneTuner`=0, `_TwoTuner`=0.0034, `_ThreeTuner`=0.0034, `_FourTuner`=0.0034, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.60)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `ProjectorMaterial_70mm` | `_ARC_ON _MAXRANGE_RANGEFIVE` | `_BaseSize`=70, `_ProjectorRadius`=30, `_BandSize`=0.278, `_BandContrast`=2.87, `_MaxRange`=4, `_Arc`=1, `_RangeSize`=-76.5, `_OneTuner`=0.0132, `_TwoTuner`=0.0045, `_ThreeTuner`=0.0175, `_FourTuner`=0.009, `_FiveTuner`=0, `_InfinityTuner`=0 | `_Color` #FFFFFF (α=0.35)
`_EmissionColor` #000000 (α=1.00)
`_RangeFive` #860070 (α=1.00)
`_RangeFour` #C20042 (α=1.00)
`_RangeInfinity` transparent
`_RangeOne` #FFC300 (α=1.00)
`_RangeThree` #FF1400 (α=1.00)
`_RangeTwo` #FF7500 (α=1.00) | -| `Projector_BoardGuide` | `m_LightmapFlags: 4` | — | `_CameraFadeParams` transparent
`_Color` #FFFFFF (α=1.00)
`_ColorAddSubDiff` transparent
`_EmissionColor` #000000 (α=1.00)
`_SoftParticleFadeParams` transparent
`_TintColor` #7F7F7F (α=0.50) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `projector_100mm` | 1 | size=31.9685, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 4b37ff3d68c5… | -| `projector_100mm_oblong` | 1 | size=33.4, far=45 | pos=(0.0, -1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 6c0556b105e8… | -| `projector_120mm` | 1 | size=32.3622, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | ab767c454926… | -| `projector_150mm` | 1 | size=32.95276, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 3a33cb678c17… | -| `projector_200mm_oblong` | 1 | size=33.4, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0)
pos=(-0.0, -0.26, -0.0) rot=(0.0, 0.0, 90.0) | cccc683b4b18… | -| `projector_27mm` | 1 | size=30.5314, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | b1c5ef617632… | -| `projector_50mm` | 1 | size=30.984253, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 20ce39a8e0e1… | -| `projector_70mm` | 1 | size=31.37795, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 977480df799a… | -| `projector_POIGuide` | 1 | size=36, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | a0fb1f64d52e… | -| `projector_bomb_cart` | 1 | size=12.98425, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 417ae88dafb1… | -| `projector_smokeToken` | 1 | size=6.3700786, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 3ef1e0f12697… | -| `projector_token` | 1 | size=7.06299, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 94ad9a7a030f… | -| `projector_tokenRangeTwo` | 1 | size=12.6, far=45 | pos=(0.0, 20.0, 0.0) rot=(90.0, 0.0, 0.0) | 0905fd93d0b7… | - -## Deployment - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `Projector_Deployment_Blue` | `m_LightmapFlags: 4` | — | `_Color` #0000FF (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Deployment_Blue_Round` | `m_LightmapFlags: 4` | — | `_Color` #0000FF (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Deployment_Red` | `m_LightmapFlags: 4` | — | `_Color` #FF0000 (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Deployment_Red_Round` | `m_LightmapFlags: 4` | — | `_Color` #FF0000 (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Score_Blue` | `m_LightmapFlags: 4` | — | `_Color` #0000FF (α=0.50)
`_EmissionColor` #000000 (α=1.00) | -| `Projector_Score_Red` | `m_LightmapFlags: 4` | — | `_Color` #FF0000 (α=0.50)
`_EmissionColor` #000000 (α=1.00) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `projector_blue_score` | 1 | size=6, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | fe66dad2ac93… | -| `projector_blue_spawn` | 1 | size=3, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 23c778da5a02… | -| `projector_blue_spawn_L` | 2 | size=1.5, far=25
size=3, far=25 | pos=(10.65, 10.71, -19.76) rot=(90.0, 0.0, 0.0)
pos=(1.5, 1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(0.0, -1.5000002, 0.0) rot=(0.0, 0.0, 90.0) | 23c778da5a02…
23c778da5a02… | -| `projector_blue_spawn_corner` | 1 | size=1.5, far=25 | pos=(10.65, 10.71, -19.76) rot=(90.0, 0.0, 0.0)
pos=(-1.5, -1.5, 0.0) rot=(0.0, 0.0, 90.0) | 23c778da5a02… | -| `projector_blue_spawn_half` | 1 | size=3, far=25 | pos=(0.0, -1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(10.65, 10.71, -19.76) rot=(90.0, 0.0, 0.0) | 23c778da5a02… | -| `projector_blue_spawn_round2` | 1 | size=12, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 34786d447192… | -| `projector_red_score` | 1 | size=6, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 90fdc53ff83c… | -| `projector_red_spawn` | 1 | size=3, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 93e068805588… | -| `projector_red_spawn_L` | 2 | size=3, far=25
size=1.5, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0)
pos=(0.0, 1.5, 0.0) rot=(0.0, 0.0, 90.0)
pos=(-1.5, -1.5, -0.0) rot=(0.0, 0.0, 90.0) | 93e068805588…
93e068805588… | -| `projector_red_spawn_corner` | 1 | size=1.5, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0)
pos=(1.5, 1.5, -0.0) rot=(0.0, 0.0, 90.0) | 93e068805588… | -| `projector_red_spawn_half` | 1 | size=3, far=25 | pos=(0.0, 1.5, -0.0) rot=(0.0, 0.0, 90.0)
pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 93e068805588… | -| `projector_red_spawn_round2` | 1 | size=12, far=25 | pos=(0.08, 10.71, -19.42) rot=(90.0, 0.0, 0.0) | 54824e17d05f… | - -## Movement - -### Materials - -| Material | Shader keywords | Floats clés | Couleurs | -|---|---|---|---| -| `ProjectorMaterial_100mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=8.622047, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_100mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=10.590551, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_100mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=12.559055, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_120mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=9.803149, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_120mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=11.771653, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_120mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=13.740157, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_150mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=11.574803, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_150mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=13.543307, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_150mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=15.511811, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed1_double` | `_MAXRANGE_RANGEINFINITY _MOVES_DOUBLE` | `_BaseSize`=27, `_ProjectorRadius`=8.45, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=4.547244, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed2_double` | `_MAXRANGE_RANGEINFINITY _MOVES_DOUBLE` | `_BaseSize`=27, `_ProjectorRadius`=12.42748, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=6.515748, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed3_double` | `_MAXRANGE_RANGEINFINITY _MOVES_DOUBLE` | `_BaseSize`=27, `_ProjectorRadius`=16.42748, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_27mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=27, `_ProjectorRadius`=8.484252, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_50mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=5.6692915, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_50mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=7.6377954, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_50mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=50, `_ProjectorRadius`=9.606299, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_70mm_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=6.850394, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_70mm_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=8.818897, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_70mm_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=10.787401, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_oblong_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=13.051181, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_oblong_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=15.019685, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_oblong_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=16.98819, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_snail_speed1_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=14.527559, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_snail_speed2_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=16.496063, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | -| `ProjectorMaterial_snail_speed3_single` | `_MAXRANGE_RANGEINFINITY _MOVES_SINGLE` | `_BaseSize`=70, `_ProjectorRadius`=18.464567, `_MaxRange`=5, `_RangeSize`=-76.5 | `_Base` #FFFFFF (α=1.00)
`_Color` #FFFFFF (α=0.48)
`_EmissionColor` #000000 (α=1.00)
`_MoveOne` #55CCFF (α=1.00)
`_MoveTwo` #357A97 (α=1.00)
`_RangeFive` #FFFF6D (α=1.00)
`_RangeFour` #FFD600 (α=1.00)
`_RangeInfinity` #FFFFFF (α=0.47)
`_RangeOne` #6C0000 (α=1.00)
`_RangeThree` #FF7B00 (α=1.00)
`_RangeTwo` #DA0000 (α=1.00) | - -### Prefabs - -| Prefab | Nb Projectors | OrthographicSize / FarClip | Transform pos/rot | Material guid | -|---|---|---|---|---| -| `movement_100mm_speed1_single` | 1 | size=8.622047, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 406c1ddb0995… | -| `movement_100mm_speed2_single` | 1 | size=10.590551, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 0aa471d50563… | -| `movement_100mm_speed3_single` | 1 | size=12.559055, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 8fe5008dea10… | -| `movement_120mm_speed1_single` | 1 | size=9.803149, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 21bc9fa53646… | -| `movement_120mm_speed2_single` | 1 | size=11.771653, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 6d02674694a9… | -| `movement_120mm_speed3_single` | 1 | size=13.740157, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 895a4d7a1f02… | -| `movement_150mm_speed1_single` | 1 | size=11.574803, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | fef40c5dadca… | -| `movement_150mm_speed2_single` | 1 | size=13.543307, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | c9d6b007a730… | -| `movement_150mm_speed3_single` | 1 | size=15.511811, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | d08a6fca8013… | -| `movement_27mm_speed1_double` | 1 | size=8.45, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 7037d7245d82… | -| `movement_27mm_speed1_single` | 1 | size=4.547244, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 61da0f6e009e… | -| `movement_27mm_speed2_double` | 1 | size=12.42748, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 362084914234… | -| `movement_27mm_speed2_single` | 1 | size=6.515748, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | 6de57d74ffda… | -| `movement_27mm_speed3_double` | 1 | size=16.42748, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | a0dce2ad51d5… | -| `movement_27mm_speed3_single` | 1 | size=8.484252, far=25 | pos=(0.08, 13.63, -19.42) rot=(90.0, 0.0, 0.0) | b65b690fde07… | -| `movement_50mm_speed1_single` | 1 | size=5.6692915, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 38f1f5f6a60c… | -| `movement_50mm_speed2_single` | 1 | size=7.6377954, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 3d2f279db06b… | -| `movement_50mm_speed3_single` | 1 | size=9.606299, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | ca54633a2d92… | -| `movement_70mm_speed1_single` | 1 | size=6.850394, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 3d2faeaaaaff… | -| `movement_70mm_speed2_single` | 1 | size=8.818897, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 948d0799a0c3… | -| `movement_70mm_speed3_single` | 1 | size=10.787401, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | a986001c0bc6… | -| `movement_oblong_speed1_single` | 1 | size=13.051181, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | b9540fc3dad6… | -| `movement_oblong_speed2_single` | 1 | size=15.019685, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | 672e50dcd47c… | -| `movement_oblong_speed3_single` | 1 | size=16.98819, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | a2468c9fe07d… | -| `movement_snail_speed1_single` | 1 | size=14.527559, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | b7c44d9516fd… | -| `movement_snail_speed2_single` | 1 | size=16.496063, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | d27d5e30c83c… | -| `movement_snail_speed3_single` | 1 | size=18.464567, far=25 | pos=(0.0, 13.63, 0.0) rot=(90.0, 0.0, 0.0) | ecdda5f21c4c… | - -## Notes globales - -- Cohesion : double set `Materials/cohesion_*` et `_Revamp/halfCohesion_*`. Probable : `_Revamp/` est la version actuellement utilisée (le bundle live s'appelle `halfcohesion_27mm.unity3d` cf shader-inventory). -- Range : shader procédural avec jusqu'à 5 bandes (`_RangeOne` à `_RangeFive`) + `_RangeInfinity`. Le keyword `_MAXRANGE_RANGEFIVE` ou `_MAXRANGE_RANGEFOUR` détermine la dernière bande visible. -- Movement : un seul Projector par prefab, paramétré par `_BaseSize` + speed. Le `_Arc` doit contrôler la portion d'arc visible. -- Deployment : 6 materials uniques réutilisés par 12 prefabs (rotations différentes pour matérialiser corner/half/L/round/score). \ No newline at end of file diff --git a/mac-patcher/patch_cohesion_rename.py b/mac-patcher/patch_cohesion_rename.py deleted file mode 100644 index 065a14a96..000000000 --- a/mac-patcher/patch_cohesion_rename.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -""" -PATCH ATTEMPT #1 — Rename embedded Shader m_Name from -'BucketheadBits/Projector/Cohesion' to 'Standard'. - -Theory: at AssetBundle.LoadAsset, Unity may resolve the shader by name via -Shader.Find() before falling back to the embedded bytecode. If so, TTS picks -up its own built-in Standard shader and renders the Cohesion projector with -it, ignoring the broken embedded shader. - -Writes to .patched.unity3d alongside the original. Does NOT touch -the live bundle in ~/Library/Tabletop Simulator/Mods/Assetbundles/ yet. -""" -import os -import sys -import shutil -import UnityPy - -BUNDLE_DIR = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles/") -BUNDLE_NAME = "httpssteamusercontentaakamaihdnetugc2482129948496305632EBBE2560D4336E6C96317EDDA787E225CF0E5B48.unity3d" -SRC = os.path.join(BUNDLE_DIR, BUNDLE_NAME) -DST = os.path.join("/tmp", "cohesion.patched.unity3d") - -print(f"Loading: {SRC}") -env = UnityPy.load(SRC) - -modified = False -for obj in env.objects: - if obj.type.name != "Shader": - continue - data = obj.read() - current = getattr(getattr(data, "m_ParsedForm", None), "m_Name", None) or getattr(data, "m_Name", None) - print(f" Found Shader PathID={obj.path_id} current m_Name={current!r}") - if current != "BucketheadBits/Projector/Cohesion": - print(" -> Not our target, skipping.") - continue - - # Modify via typetree (safest cross-version path with UnityPy) - tree = obj.read_typetree() - # m_ParsedForm.m_Name is the canonical name for parsed shaders. - # m_Name may also exist for the asset itself; we try both. - if "m_ParsedForm" in tree and "m_Name" in tree["m_ParsedForm"]: - old = tree["m_ParsedForm"]["m_Name"] - tree["m_ParsedForm"]["m_Name"] = "Standard" - print(f" -> renamed m_ParsedForm.m_Name {old!r} -> 'Standard'") - modified = True - if "m_Name" in tree and tree.get("m_Name"): - old = tree["m_Name"] - tree["m_Name"] = "Standard" - print(f" -> renamed m_Name {old!r} -> 'Standard'") - modified = True - - obj.save_typetree(tree) - -if not modified: - print("ERROR: no shader matched. Aborting.") - sys.exit(1) - -# Write out the patched bundle -print(f"\nSaving patched bundle to: {DST}") -with open(DST, "wb") as f: - f.write(env.file.save(packer="lz4")) - -print(f" size: {os.path.getsize(DST)} bytes (original was {os.path.getsize(SRC)})") - -# Verify roundtrip -print("\nVerifying patched bundle is re-loadable...") -env2 = UnityPy.load(DST) -for obj in env2.objects: - if obj.type.name == "Shader": - d = obj.read() - n = getattr(getattr(d, "m_ParsedForm", None), "m_Name", None) or getattr(d, "m_Name", None) - print(f" After patch: Shader PathID={obj.path_id} m_Name={n!r}") - elif obj.type.name == "Material": - d = obj.read() - sref = d.m_Shader - print(f" Material {d.m_Name!r} m_Shader=(file_id={sref.file_id}, path_id={sref.path_id})") - -print("\nDone. Patched file ready at:", DST) -print("Run this command to replace the live bundle (only when TTS is closed):") -print(f" cp {DST!r} {SRC!r}") diff --git a/mac-patcher/patch_save_for_mac.py b/mac-patcher/patch_save_for_mac.py index 24bd5e227..0e9dc5694 100644 --- a/mac-patcher/patch_save_for_mac.py +++ b/mac-patcher/patch_save_for_mac.py @@ -1,16 +1,24 @@ #!/usr/bin/env python3 """ -Patch a TTS save JSON (SWL mod beta) to add Mac Cohesion fallback. +Inject the optional Iron Squadron overlay module into a SWL TTS save JSON. + +It sits behind a single table-wide button, off by default, and adds what the +vanilla mod does not have: cohesion on the five base sizes it skips, cohesion +that follows a model while it is dragged, deterministic toggles instead of +respawns, a maximum-move template anchored where the move started, and a range +0.5 band on the range templates. Rendering goes through real Projectors, in +bundles built like the repaired ones, so nothing is drawn in Lua. + +Not to be confused with the magenta fix: that one lives in the bundles, and is +this branch's mergeable content. This module is the separate offer described in +proposals-upstream.txt. The names below still say "mac" for historical reasons +and because the injected markers are matched by regex on re-runs. Two-part patch: 1. Append manager + event handlers to the Global LuaScript. 2. Replace the inline Cohesion block in Unit_Leader (99f1c8) and Order_Token (a57c41) with delegated stubs that call the Global manager via Global.call. -The native hotkey "Show Cohesion On Hovered Model" and the Order Token -"COHESION" button automatically use the patched code afterwards - they -spawn vector-lines rings instead of legacy Custom_AssetBundle Projectors. - Usage: python3 patch_save_for_mac.py """ @@ -43,115 +51,26 @@ clearCohesionRulerOriginalGlobal = clearCohesionRuler activeOverlays = activeOverlays or {} -hiddenWhilePickedUp = hiddenWhilePickedUp or {} - -local ASSETS_BASE = "https://raw.githubusercontent.com/ironsquadronfr-hub/tts/mac-projector-fallback/mod/data/mac-fallback-assets/" -local COHESION_HALO_URL = ASSETS_BASE .. "cohesion_halo.png" -local MAX_MOVE_CYAN_URL = ASSETS_BASE .. "max_move_cyan.png" -local DEPLOYMENT_RED_URL = ASSETS_BASE .. "deployment_red.png" -local DEPLOYMENT_BLUE_URL = ASSETS_BASE .. "deployment_blue.png" -local RANGE_DECAL_URLS = { - smokeToken = ASSETS_BASE .. "range_smokeToken.png", - token = ASSETS_BASE .. "range_token.png", - tokenRangeTwo = ASSETS_BASE .. "range_tokenRangeTwo.png", - poi = ASSETS_BASE .. "range_poi.png", - bombCart = ASSETS_BASE .. "range_bombCart.png", - -- Fig leaders: per-base-size PNGs (bands pre-offset by the base radius so - -- the decal bands line up with the vector-line rings). - fig_leader = { - small = ASSETS_BASE .. "range_fig_leader_small.png", - medium = ASSETS_BASE .. "range_fig_leader_medium.png", - large = ASSETS_BASE .. "range_fig_leader_large.png", - huge = ASSETS_BASE .. "range_fig_leader_huge.png", - laat = ASSETS_BASE .. "range_fig_leader_laat.png", - epic = ASSETS_BASE .. "range_fig_leader_epic.png", - long = ASSETS_BASE .. "range_fig_leader_long_stadium.png", - snail = ASSETS_BASE .. "range_fig_leader_snail_stadium.png", - }, -} -local RANGE_MAX_RADIUS = { - fig_leader = 30, -- R5 max - -- SWL: Range 1 = 6", Range 2 = 12", Range 0.5 = 3". - smokeToken = 6, - token = 6, - tokenRangeTwo = 12, - poi = 3, - bombCart = 12, -} --- Range band colors (RGBA) extracted from BB_RangeProjector material defaults --- + demi-portee blanche pour SWL Range 0.5 (3 inches) -local RANGE_COLORS = { - half = {1.0, 1.0, 1.0, 0.6}, -- 0.5 = 3" white - one = {1.0, 0.764, 0.0, 0.7}, -- R1 yellow - two = {1.0, 0.459, 0.0, 0.7}, -- R2 orange - three = {1.0, 0.079, 0.0, 0.7}, -- R3 red - four = {0.764, 0.0, 0.259, 0.7}, -- R4 magenta - five = {0.528, 0.0, 0.443, 0.7}, -- R5 violet -} - --- Per-rangeKey configurations: list of {radius_inches, color_name} -local RANGE_CONFIGS = { - -- Fig leaders: half + 4 bands at SWL standard ranges 3/6/12/18/24" - small = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - medium = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - large = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - huge = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - laat = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - epic = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - long = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - snail = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - -- Token rangeKeys (1 to 2 bands depending on use). SWL ranges in inches: - -- Range 1 = 6", Range 2 = 12", Range 0.5 (poi) = 3". - smokeToken = {{r=6,c="one"}}, - token = {{r=6,c="one"}}, - tokenRangeTwo = {{r=6,c="one"},{r=12,c="two"}}, - poi = {{r=3,c="one"}}, - bombCart = {{r=6,c="one"},{r=12,c="two"}}, -} - --- Base footprint dimensions in mm. width <= length; equal means round. --- Used to drive round vs stadium overlay shape. -local BASE_DIMENSIONS = { - small = {width = 27, length = 27}, - medium = {width = 50, length = 50}, - large = {width = 70, length = 70}, - huge = {width = 100, length = 100}, - laat = {width = 120, length = 120}, - epic = {width = 150, length = 150}, - long = {width = 100, length = 175}, -- oblong - snail = {width = 100, length = 200}, -- oblong -} - --- Base radius (inches) for round overlays. For oblong bases we use a --- stadium shape instead (see macBuildStadium), so the radius here is the --- half-width that drives the cap arcs. -local FIG_BASE_RADIUS_IN = {} -for k, d in pairs(BASE_DIMENSIONS) do - FIG_BASE_RADIUS_IN[k] = (d.width / 2) / 25.4 -end - -local function macIsOblong(baseSize) - local d = BASE_DIMENSIONS[baseSize] - return d ~= nil and d.length > d.width -end - --- Returns the stadium half-dimensions (inches) and yaw for an oblong base, --- or nil if the fig's base is round. Callers should branch on the return. -local function macOblongDims(fig, baseSize) - if not macIsOblong(baseSize) then return nil end - local d = BASE_DIMENSIONS[baseSize] - return { - halfLen = (d.length / 2) / 25.4, - halfWid = (d.width / 2) / 25.4, - rotY = fig.getRotation().y, - } -end +-- ============================================ +-- RENDERER: one real Unity Projector per active overlay. +-- +-- Replaces the flat-decal renderer. A Projector drapes over table relief and +-- tracks its figure natively, so the ground raycasts, the hand-built +-- ring/rect/stadium geometry, the per-entry geometry cache, the coalesced +-- redraw signature and the PNG preload all went away with it. +-- +-- What this layer still owns, and the vanilla mod does not: +-- * cohesion stays visible and FOLLOWS the figure during a drag; +-- * toggles are deterministic (see gRangeTrigger / gCohesionTrigger); +-- * MaxMove is anchored where the move STARTED and never follows. +-- Spec: the Iron Squadron overlay spec (kept internal, ask us for it). +-- ============================================ -- Resolve baseSize for a fig. The mod stores it as a script-local Var on the -- fig itself (set at spawn from the unit's container). unitData.baseSize is --- only populated on the Unit Leader, not on minis/vehicles - so getVar is --- the canonical read, with unitData as fallback for safety. +-- only populated on the Unit Leader, not on minis/vehicles, so getVar is the +-- canonical read with unitData as a fallback. local function macGetBaseSize(fig) if not fig then return nil end local ok, bs = pcall(function() return fig.getVar("baseSize") end) @@ -161,24 +80,9 @@ return nil end -local function macFigBaseRadius(fig) - if not fig then return 0.5315 end - local bs = macGetBaseSize(fig) - return (bs and FIG_BASE_RADIUS_IN[bs]) or 0.5315 -end - --- Token base radii (inches) - measured from token edge for range bands -local TOKEN_BASE_RADIUS = { - smokeToken = 18.8 / 2 / 25.4, -- 0.370" - token = 25.1 / 2 / 25.4, -- 0.494" - tokenRangeTwo = 25.1 / 2 / 25.4, -- 0.494" - poi = 50.8 / 2 / 25.4, -- 1.000" - bombCart = 50.0 / 2 / 25.4, -- 0.984" -} - --- Hotkey-driven range on tokens renders fig-leader-style bands using the --- closest equivalent base size (so the visual matches what a unit of that --- footprint would see). Token R button keeps the single ring (token-spec). +-- A token has no baseSize of its own. When the hover hotkey asks for the full +-- fig-leader bands on a token, size them with the closest equivalent +-- footprint. The token's own R button keeps its single-ring bundle. local TOKEN_TO_BASESIZE = { smokeToken = "small", token = "small", @@ -187,546 +91,256 @@ bombCart = "medium", } --- Maximum Move radii (inches), indexed by baseSize then speed 1..3 --- Source: ProjectorRadius from BB_MovementProjector materials (single variants) -local MAX_MOVE_RADIUS = { - small = { 4.547244, 6.515748, 8.484252}, -- 27mm - medium = { 5.669292, 7.637795, 9.606299}, -- 50mm - large = { 6.850394, 8.818897, 10.787401}, -- 70mm - huge = { 8.622047, 10.590551, 12.559055}, -- 100mm - laat = { 9.803149, 11.771653, 13.740157}, -- 120mm - epic = {11.574803, 13.543307, 15.511811}, -- 150mm - long = {13.051181, 15.019685, 16.988190}, -- oblong - snail = {14.527559, 16.496063, 18.464567}, -- snail +-- The Iron Squadron range rulers. Same Projector, same geometry, same colours +-- as the mod's, with one thing added: the range 0.5 band, white, from the base +-- edge out to 3in, with range 1's orange starting at 3in instead of at the +-- base. Built from the mod's own prefabs (see make_isq_range_prefabs.py), so +-- they are bi-platform like everything else we rebuild. +-- +-- These are NEW assets: they have no Steam UGC entry, so they cannot be served +-- from the TTS cache like the 143 repaired bundles. They are served from the +-- fork instead. That separation is the point: the repaired bundles sit under +-- the mod's own URLs, so anything added to them would show in the mod's +-- original mode too, and the toggle would stop meaning anything for Range. +-- +-- Only the hover hotkey reaches this table (forceFigMode). A token's own R +-- button keeps the mod's single-ring bundle, deliberately. +local ISQ_ASSETS = "__ISQ_ASSETS_BASE__" +local ISQ_RANGE_BUNDLES = { + small = ISQ_ASSETS .. "projector_27mm_isq_v6.unity3d", + medium = ISQ_ASSETS .. "projector_50mm_isq_v6.unity3d", + large = ISQ_ASSETS .. "projector_70mm_isq_v6.unity3d", + huge = ISQ_ASSETS .. "projector_100mm_isq_v6.unity3d", + laat = ISQ_ASSETS .. "projector_120mm_isq_v6.unity3d", + epic = ISQ_ASSETS .. "projector_150mm_isq_v6.unity3d", + long = ISQ_ASSETS .. "projector_100mm_oblong_isq_v6.unity3d", + snail = ISQ_ASSETS .. "projector_200mm_oblong_isq_v6.unity3d", } --- Deployment cell dimensions (width X, depth Z, inches). --- All cells live on a 6"x6" grid; sub-cells are halves/quarters of that cell. -local DEPLOYMENT_CELL_SIZES = { - r = {6, 6}, b = {6, 6}, -- full cell - rl = {6, 6}, bl = {6, 6}, -- large variant (also full cell) - rh = {6, 3}, bh = {6, 3}, -- horizontal half (3" deep) - rs = {3, 6}, bs = {3, 6}, -- vertical half (3" wide), x-offset +1.5 - rss = {3, 6}, bss = {3, 6}, -- vertical half (3" wide), x-offset -1.5 - rc = {3, 3}, bc = {3, 3}, -- corner quarter - rcc = {3, 3}, bcc = {3, 3}, -- opposite corner quarter +-- Cohesion rings for the base sizes the mod has none for. getCohesionLinks() +-- only knows small (27mm), medium (50mm) and large (70mm); on anything else +-- vanilla spawnCohesionRuler returns without drawing. These fill the gap. +-- +-- Nothing was invented: BB_CohesionProjector draws its ring at the projector's +-- outer edge and reads nothing else, so the whole geometry is one number, +-- ortho = half the base + 3in, which is what the mod's own three carry +-- (27mm -> 3.531496 = 0.53150 + 3, and so on). 3in is range 1/2, the distance +-- cohesion is measured at -- the same one the white band marks on the rulers. +-- +-- long and snail go through BB_OblongCohesionProjector, written for them: the +-- mod's cohesion shader computes its gradient with Circle() and can only draw +-- a disc. Their base geometry is not invented either -- it is read back out of +-- the oblong RANGE materials, the only place in the project where those +-- dimensions are written down. +local ISQ_COHESION_BUNDLES = { + huge = ISQ_ASSETS .. "halfcohesion_100mm_isq_v2.unity3d", + laat = ISQ_ASSETS .. "halfcohesion_120mm_isq_v2.unity3d", + epic = ISQ_ASSETS .. "halfcohesion_150mm_isq_v2.unity3d", + long = ISQ_ASSETS .. "halfcohesion_long_isq_v3.unity3d", + snail = ISQ_ASSETS .. "halfcohesion_snail_isq_v3.unity3d", } -local function macRayGroundY(x, z, offset) - local hits = Physics.cast({ - origin = {x, 30, z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 50, - }) - for _, h in ipairs(hits) do - return h.point.y + (offset or 0.05) - end - return offset or 0.05 -end +-- Range 1/2 rings for the tokens whose own R button should show range 1/2 +-- rather than the range 1 the mod gives them. Keyed by the object's name, +-- because rangeKey cannot separate them: "token" is shared by Objective, +-- Condition, Cad Bane, Proton Charge and Complete the Mission alike. +-- +-- The mod already had exactly this object, and it is not a range ruler: the +-- POI's 3in ring is BB_CohesionProjector with its orthographic size at 4, +-- one inch of base radius plus three. Ours is the same build at the token's +-- own base, and it keeps the ring's current amber so only the SIZE changes. +-- +-- The hover hotkey is untouched: it still draws the fig-leader bands. +-- Objective and Condition share the base and the colour, so one ring serves +-- both. Condition is what the game calls the Advantage token (Martin, 14 Aug). +local ISQ_TOKEN_RINGS = { + ["Objective Token"] = ISQ_ASSETS .. "token05_27mm_isq_v1.unity3d", + ["Condition Token"] = ISQ_ASSETS .. "token05_27mm_isq_v1.unity3d", +} -local function macBuildRect(cx, cz, hw, hd, color, thickness) - local segPerSide = 8 - local pts = {} - local corners = { - {cx - hw, cz + hd}, {cx + hw, cz + hd}, - {cx + hw, cz - hd}, {cx - hw, cz - hd}, - } - for i = 1, 4 do - local a = corners[i] - local b = corners[i % 4 + 1] - for s = 0, segPerSide - 1 do - local t = s / segPerSide - local x = a[1] + (b[1] - a[1]) * t - local z = a[2] + (b[2] - a[2]) * t - table.insert(pts, {x, macRayGroundY(x, z), z}) - end - end - local c1 = corners[1] - table.insert(pts, {c1[1], macRayGroundY(c1[1], c1[2]), c1[2]}) - return {points = pts, color = color, thickness = thickness or 0.06} -end +-- Per family: the name TTS gives the spawned object, whether it tracks its +-- figure, and the pitch the vanilla spawn uses. +-- +-- The names are deliberately the vanilla ones. standbyTokens (Global) and +-- removeLockedRulers (GAME_CONTROLLER) sweep by name, so our Projectors +-- inherit the mod's own cleanup; the decal renderer had no objects at all and +-- was invisible to both. +local PROJECTOR_SPEC = { + range = {name = "Range Ruler", follows = true, pitch = 90}, + cohesion = {name = "Cohesion Ruler", follows = true, pitch = 0}, + maxmove = {name = "Maximum Move", follows = false, pitch = 0}, +} -local function macBuildRing(centerPos, radius, color, thickness, ignoreObj) - local nSeg = 64 - local pts = {} - for i = 0, nSeg do - local a = (i / nSeg) * 2 * math.pi - local x = centerPos.x + radius * math.cos(a) - local z = centerPos.z + radius * math.sin(a) - local hits = Physics.cast({ - origin = {x, centerPos.y + 10, z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local y = centerPos.y + 0.05 - for _, h in ipairs(hits) do - if h.hit_object ~= ignoreObj then - y = h.point.y + 0.05 - break - end - end - table.insert(pts, {x, y, z}) - end - return {points = pts, color = color, thickness = thickness or 0.05} +-- Vanilla injects this into its Range ruler so it tracks the figure. Reusing +-- it for cohesion is what makes cohesion follow during a drag. Guarded +-- against a destroyed target, which the vanilla one is not. +local function macFollowScript(targetGUID) + return "targetGUID = '" .. targetGUID .. "'\n" + .. "function onFixedUpdate()\n" + .. " local t = getObjectFromGUID(targetGUID)\n" + .. " if t == nil then return end\n" + .. " local p = t.getPosition()\n" + .. " self.setPosition({p.x, p.y + 20, p.z})\n" + .. " self.setRotation({0, t.getRotation().y, 0})\n" + .. "end" end --- Stadium = rectangle with two semicircular caps on the long-axis ends. --- halfLen, halfWid in inches (halfLen >= halfWid). dist = expansion outwards --- from the base edge in inches (so the overlay sits at dist outside the base). --- rotYdeg rotates the stadium around Y to match the fig's orientation; the --- long axis is local Z (front-to-back of the model). -local function macBuildStadium(centerPos, halfLen, halfWid, rotYdeg, dist, color, thickness, ignoreObj) - local rotRad = math.rad(rotYdeg or 0) - local cosR, sinR = math.cos(rotRad), math.sin(rotRad) - - local capCenter = halfLen - halfWid -- distance from center to cap arc center, along long axis - local capR = halfWid + dist -- cap arc radius (= straight-side offset from long axis) - - local nSegArc = 32 -- segments per semicircle cap - local pts = {} - - local function addLocal(lx, lz) - local wx = centerPos.x + lx * cosR + lz * sinR - local wz = centerPos.z - lx * sinR + lz * cosR - local hits = Physics.cast({ - origin = {wx, centerPos.y + 10, wz}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local y = centerPos.y + 0.05 - for _, h in ipairs(hits) do - if h.hit_object ~= ignoreObj then - y = h.point.y + 0.05 - break - end - end - table.insert(pts, {wx, y, wz}) - end - - -- Top cap: angle 0 to pi (right edge -> top -> left edge), centered at z=+capCenter - for i = 0, nSegArc do - local a = (i / nSegArc) * math.pi - addLocal(capR * math.cos(a), capCenter + capR * math.sin(a)) - end - -- The straight left side is implicit: the polyline jumps from - -- (-capR, +capCenter) directly to (-capR, -capCenter) - TTS draws a - -- straight segment between consecutive points. - -- Bottom cap: angle pi to 2pi, centered at z=-capCenter - for i = 0, nSegArc do - local a = math.pi + (i / nSegArc) * math.pi - addLocal(capR * math.cos(a), -capCenter + capR * math.sin(a)) - end - -- Close the loop back to the top-cap start point. - addLocal(capR, capCenter) - - return {points = pts, color = color, thickness = thickness or 0.05} +local function macIsLive(obj) + if obj == nil then return false end + local ok, dead = pcall(function() return obj.isDestroyed() end) + return ok and not dead end -local macBuilders = {} - -macBuilders.range = function(fig, params) - local pos = fig.getPosition() - -- Resolve rangeKey for token-specific single-ring rendering (R button on - -- a token). The hotkey path sets params.forceFigMode = true to bypass - -- this and always render the fig-leader 6-band overlay, using the token's - -- equivalent baseSize for sizing. - local forceFig = params and params.forceFigMode - local rangeKey = (not forceFig) and ((params and params.rangeKey) or fig.getVar("rangeKey")) or nil - local config, decalURL, maxR, br - local oblong, halfLen, halfWid, rotY - local baseSize -- hoisted: used below for firing-arc decision - if rangeKey and RANGE_CONFIGS[rangeKey] then - config = RANGE_CONFIGS[rangeKey] - decalURL = RANGE_DECAL_URLS[rangeKey] - maxR = RANGE_MAX_RADIUS[rangeKey] - br = TOKEN_BASE_RADIUS[rangeKey] or 0 - else - baseSize = macGetBaseSize(fig) - local hasBaseSize = baseSize ~= nil - if not hasBaseSize then - -- Token hit from hotkey: map rangeKey to base size for sizing. - local tk = fig.getVar("rangeKey") - baseSize = (tk and TOKEN_TO_BASESIZE[tk]) or "small" - end - config = RANGE_CONFIGS[baseSize] or RANGE_CONFIGS.small - decalURL = RANGE_DECAL_URLS.fig_leader[baseSize] - or RANGE_DECAL_URLS.fig_leader.small - maxR = RANGE_MAX_RADIUS.fig_leader - if hasBaseSize then - br = macFigBaseRadius(fig) - local od = macOblongDims(fig, baseSize) - if od then - oblong, halfLen, halfWid, rotY = true, od.halfLen, od.halfWid, od.rotY - end - else - -- Token in forceFig mode: use token's measured radius for offset. - local tk = fig.getVar("rangeKey") - br = (tk and TOKEN_BASE_RADIUS[tk]) or FIG_BASE_RADIUS_IN[baseSize] or 0.5315 - end +-- Which bundle this overlay shows. MaxMove carries its own in params: +-- getMovementLinks lives in the Order Token's scope, not reachable from +-- Global. +local function macResolveBundle(kind, fig, params) + params = params or {} + if params.bundle then return params.bundle end + + if kind == "cohesion" then + local bs = macGetBaseSize(fig) + if not bs then return nil end + -- Ours first, for the base sizes the mod has no ring for at all. + if ISQ_COHESION_BUNDLES[bs] then return ISQ_COHESION_BUNDLES[bs] end + if not getCohesionLinks then return nil end + local links = getCohesionLinks() + return links and links[bs] or nil end - -- SWL convention: all distances are measured from the base/token edge. + if kind == "range" then + if not getRangeRulerLinks then return nil end + local links = getRangeRulerLinks() + if not links then return nil end - -- Vector contours: 6 concentric rings for round bases, 6 concentric - -- stadium outlines for oblong bases (each band sits at band.r outwards - -- from the base edge - same rule, just a different shape). - local lines = {} - for _, band in ipairs(config) do - if oblong then - table.insert(lines, macBuildStadium(pos, halfLen, halfWid, rotY, band.r, - RANGE_COLORS[band.c], 0.1, fig)) - else - table.insert(lines, macBuildRing(pos, band.r + br, RANGE_COLORS[band.c], 0.1, fig)) + local key = params.rangeKey + if not key and fig then + local ok, v = pcall(function() return fig.getVar("rangeKey") end) + if ok then key = v end end - end - - -- Firing arc lines (4 radial lines dividing into Front/R/Rear/L quadrants). - -- Mirrors the vanilla Projector shader: ProjectorMaterial_ sets - -- _Arc=1 / shader keyword _ARC_ON for every non-27mm base (medium, large, - -- huge, laat, epic, long, snail). 27mm sets _Arc=0. - -- Round bases : lines emanate from the base edge, radial from center. - -- Oblong bases: lines emanate from the centers of the two end - -- semicircles (front cap + rear cap), front lines at +/-45 from the - -- long-axis forward, rear lines at +/-135. - local arcColor = {1, 1, 1, 0.6} - if baseSize and baseSize ~= "small" then - if oblong then - local capOffset = (halfLen or 0) - (halfWid or 0) - local rad = math.rad(rotY) - local fwdX, fwdZ = math.sin(rad), math.cos(rad) - local frontX = pos.x + capOffset * fwdX - local frontZ = pos.z + capOffset * fwdZ - local rearX = pos.x - capOffset * fwdX - local rearZ = pos.z - capOffset * fwdZ - local reach = (halfWid or 0) + (maxR or 6) - local arcs = { - {frontX, frontZ, 45}, -- front-right boundary - {frontX, frontZ, -45}, -- front-left boundary - {rearX, rearZ, 135}, -- rear-right boundary - {rearX, rearZ, -135}, -- rear-left boundary - } - for _, e in ipairs(arcs) do - local cx, cz, deg = e[1], e[2], e[3] - local a = math.rad(rotY + deg) - local sinA, cosA = math.sin(a), math.cos(a) - table.insert(lines, { - points = { - {cx, pos.y + 0.1, cz}, - {cx + reach * sinA, pos.y + 0.1, cz + reach * cosA}, - }, - color = arcColor, - thickness = 0.05, - }) - end - else - local figRotY = fig.getRotation().y - local outerR = (maxR or 6) + br - for _, deg in ipairs({45, 135, 225, 315}) do - local a = math.rad(deg + figRotY) - local sinA, cosA = math.sin(a), math.cos(a) - table.insert(lines, { - points = { - {pos.x + br * sinA, pos.y + 0.1, pos.z + br * cosA}, - {pos.x + outerR * sinA, pos.y + 0.1, pos.z + outerR * cosA}, - }, - color = arcColor, - thickness = 0.05, - }) - end + -- Token R button: its own single-ring bundle. Hover hotkey + -- (forceFigMode): the full fig-leader bands instead. + if key and not params.forceFigMode then + local ok, nm = pcall(function() return fig.getName() end) + if ok and nm and ISQ_TOKEN_RINGS[nm] then return ISQ_TOKEN_RINGS[nm] end + if links[key] then return links[key] end end + local bs = macGetBaseSize(fig) or (key and TOKEN_TO_BASESIZE[key]) + if not bs then return nil end + -- Iron Squadron rulers first: same geometry as the mod's, plus the + -- white range 0.5 band. Fall back to the mod's own bundle if a base + -- size ever appears that we have no ruler for. + return ISQ_RANGE_BUNDLES[bs] or links[bs] end - -- Decal halo (filled bands) - flat on the ground under the fig - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then groundY = h.point.y; break end - end - - if oblong then - -- Stadium-shape PNGs are authored per oblong base size: the 6 bands - -- inside the PNG already match the stadium vector outlines because - -- the PNG was generated with the same halfWid/halfLen + dist math. - -- Decal scale = footprint of the outermost band (2*(half + maxR)) - -- in each axis, so the PNG stretches to exactly the right size. - local longSpan = 2 * (halfLen + maxR) - local shortSpan = 2 * (halfWid + maxR) - return { - lines = lines, - decals = { - { - name = "range_decal_" .. fig.getGUID(), - url = decalURL, - position = {pos.x, groundY + 0.02, pos.z}, - rotation = {90, rotY, 0}, - scale = {shortSpan, longSpan, 1}, - } - }, - } - end - - local effectiveMaxR = maxR + br - return { - lines = lines, - decals = { - { - name = "range_decal_" .. fig.getGUID(), - url = decalURL, - position = {pos.x, groundY + 0.02, pos.z}, -- below cohesion halo - rotation = {90, 0, 0}, - scale = {effectiveMaxR * 2, effectiveMaxR * 2, effectiveMaxR * 2}, - } - }, - } + return nil end -macBuilders.maxmove = function(fig, params) - -- Use the anchor position stored at spawn time so the ring stays put - -- when the fig slides toward the destination, instead of tracking it. - local pos = (params and params.anchorPos) or fig.getPosition() - local baseSize = (params and params.baseSize) or "small" - local speed = (params and params.speed) or 1 - local radii = MAX_MOVE_RADIUS[baseSize] or MAX_MOVE_RADIUS.small - local r = radii[speed] or radii[1] - local color = {0.333, 0.800, 1.000, 0.7} - -- Base ring: small white outline matching the figure's base - -- (BB_MovementProjector renders a base ring on top of the cyan max-move). - local br = FIG_BASE_RADIUS_IN[baseSize] or FIG_BASE_RADIUS_IN.small - local baseColor = {1, 1, 1, 0.8} - -- Oblong base dimensions for the stadium base ring. Use the rotation - -- captured at spawn-time so the ring keeps its orientation even after - -- the fig pivots toward its destination. - local od = macOblongDims(fig, baseSize) - local oblong = od ~= nil - local halfLen, halfWid, rotY - if oblong then - halfLen, halfWid = od.halfLen, od.halfWid - rotY = (params and params.anchorRot) or od.rotY +local function macSpawnProjector(kind, fig, params) + local spec = PROJECTOR_SPEC[kind] + if not spec then return nil end + local bundle = macResolveBundle(kind, fig, params) + if not bundle then return nil end + params = params or {} + + local pos, yaw + if kind == "maxmove" then + -- Anchored: position and yaw captured when the move STARTED, so the + -- template stays where it was while the figure slides away from it. + local a = params.anchorPos + if not a then return nil end + pos = {a.x, a.y + 20, a.z} + yaw = params.anchorRot or 0 + else + if not fig then return nil end + local p = fig.getPosition() + pos = {p.x, p.y + 20, p.z} + yaw = fig.getRotation().y end - - -- Ground raycast for decal position - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, + if not pos then return nil end + + local obj = spawnObject({ + type = "Custom_AssetBundle", + position = pos, + rotation = {spec.pitch, yaw, 0}, + -- Scale 0 hides the TTS placeholder box without touching the + -- Projector itself, exactly as the vanilla spawns do. + scale = {0, 0, 0}, }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then groundY = h.point.y; break end - end - - if oblong then - -- Oblong base: the cyan max-move envelope stays a round ring (isotropic - -- - any rotation reaches the same radius from the pivot), but the - -- inner white base ring follows the rectangular footprint as a - -- stadium hugging the actual base edge. - return { - lines = { - macBuildRing(pos, r, color, 0.1, fig), - macBuildStadium(pos, halfLen, halfWid, rotY, 0, - baseColor, 0.06, fig), - }, - decals = { - { - name = "maxmove_decal_" .. fig.getGUID(), - url = MAX_MOVE_CYAN_URL, - position = {pos.x, groundY + 0.025, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } + obj.setCustomObject({type = 0, assetbundle = bundle}) + obj.setLock(true) + obj.use_gravity = false + obj.setName(spec.name) + if spec.follows and fig then + obj.setLuaScript(macFollowScript(fig.getGUID())) end - - return { - lines = { - macBuildRing(pos, r, color, 0.1, fig), -- outer cyan max-move - macBuildRing(pos, br, baseColor, 0.06, fig), -- inner white base ring - }, - decals = { - { - name = "maxmove_decal_" .. fig.getGUID(), - url = MAX_MOVE_CYAN_URL, - position = {pos.x, groundY + 0.025, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } + return obj end -macBuilders.deployment = function(_, params) - local pos = params.pos -- {x, y, z} cell center (after offset) - local cell = params.cell -- "r"/"b"/"rh"/... key - local size = DEPLOYMENT_CELL_SIZES[cell] or {6, 6} - local w, d = size[1], size[2] - local cx, cz = pos[1], pos[3] - local hw, hd = w / 2, d / 2 - - local isRed = cell:sub(1, 1) == "r" - local lineColor = isRed and {1, 0.15, 0.15, 0.9} or {0.15, 0.4, 1, 0.9} - local url = isRed and DEPLOYMENT_RED_URL or DEPLOYMENT_BLUE_URL - - local groundY = macRayGroundY(cx, cz, 0) - - return { - lines = { macBuildRect(cx, cz, hw, hd, lineColor, 0.08) }, - decals = { - { - name = "deployment_decal_" .. cell .. "_" .. tostring(cx) .. "_" .. tostring(cz), - url = url, - position = {cx, groundY + 0.03, cz}, - rotation = {90, 0, 0}, - scale = {w, d, 1}, - } - }, - } -end - -macBuilders.cohesion = function(fig, params) - local pos = fig.getPosition() - -- Cohesion overlay = base edge + 3 inches (half Range 1). Round bases get - -- a ring; oblong bases (long/snail) get a stadium-shape concentric to the - -- footprint so the offset stays a constant 3" from the edge. - local rangeKey = fig.getVar("rangeKey") - local oblong, halfLen, halfWid, rotY, r - if rangeKey and TOKEN_BASE_RADIUS[rangeKey] then - r = TOKEN_BASE_RADIUS[rangeKey] + 3.0 - else - local baseSize = macGetBaseSize(fig) or "small" - local od = macOblongDims(fig, baseSize) - if od then - oblong, halfLen, halfWid, rotY = true, od.halfLen, od.halfWid, od.rotY - else - r = (FIG_BASE_RADIUS_IN[baseSize] or FIG_BASE_RADIUS_IN.small) + 3.0 - end - end - if params and params.radius then - oblong, r = false, params.radius - end - - -- Find ground level under the fig so the flat decal sits on the table, - -- not at the fig pivot (which is above the base). - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then - groundY = h.point.y - break - end +-- Destroy the Projector an entry owns, then forget the entry. EVERY removal +-- must go through here: dropping the entry alone leaks the object. +function macRemove(key) + local entry = activeOverlays[key] + if entry == nil then return end + if macIsLive(entry.obj) then + pcall(function() entry.obj.destruct() end) end + activeOverlays[key] = nil +end - if oblong then - -- Halo decal: stretch the round halo PNG into an ellipse aligned with - -- the fig's long axis. Approximates the stadium fade (a true stadium - -- halo would need a custom PNG); good enough visually since the - -- vector stadium outline carries the precise boundary. - local longSpan = 2 * (halfLen + 3.0) - local shortSpan = 2 * (halfWid + 3.0) - return { - lines = { macBuildStadium(pos, halfLen, halfWid, rotY, 3.0, - {1, 1, 1, 0.8}, 0.06, fig) }, - decals = { - { - name = "cohesion_halo_" .. fig.getGUID(), - url = COHESION_HALO_URL, - position = {pos.x, groundY + 0.04, pos.z}, - -- 90 deg pitch lays the decal flat on the table; rotY (yaw) - -- aligns its local Y with the fig's long axis. - rotation = {90, rotY, 0}, - scale = {shortSpan, longSpan, 1}, - } - }, - } +function macRemoveAllOfType(kind) + local keys = {} + for key, entry in pairs(activeOverlays) do + if entry.type == kind then keys[#keys + 1] = key end end - - return { - lines = { macBuildRing(pos, r, {1, 1, 1, 0.8}, 0.06, fig) }, - decals = { - { - name = "cohesion_halo_" .. fig.getGUID(), - url = COHESION_HALO_URL, - position = {pos.x, groundY + 0.04, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } + for _, key in ipairs(keys) do macRemove(key) end end -function macRedrawAll() - local lines, decals = {}, {} - -- Preload all texture URLs (invisible decals far below the table) so TTS - -- keeps them cached and we never get the white-square flash on respawn. - local preloadURLs = { - COHESION_HALO_URL, - RANGE_DECAL_URLS.smokeToken, - RANGE_DECAL_URLS.token, RANGE_DECAL_URLS.tokenRangeTwo, - RANGE_DECAL_URLS.poi, RANGE_DECAL_URLS.bombCart, - RANGE_DECAL_URLS.fig_leader.small, RANGE_DECAL_URLS.fig_leader.medium, - RANGE_DECAL_URLS.fig_leader.large, RANGE_DECAL_URLS.fig_leader.huge, - RANGE_DECAL_URLS.fig_leader.laat, RANGE_DECAL_URLS.fig_leader.epic, - RANGE_DECAL_URLS.fig_leader.long, RANGE_DECAL_URLS.fig_leader.snail, - MAX_MOVE_CYAN_URL, - DEPLOYMENT_RED_URL, DEPLOYMENT_BLUE_URL, - } - for i, url in ipairs(preloadURLs) do - table.insert(decals, { - name = "preload_" .. i, - url = url, - position = {0, -200 - i * 0.01, 0}, - rotation = {90, 0, 0}, - scale = {0.01, 0.01, 0.01}, - }) - end +-- Reconcile the scene with the registry: give a Projector to every entry that +-- has never had one, and drop entries that lost theirs. +function macRedrawNow() local stale = {} for key, entry in pairs(activeOverlays) do - local b = macBuilders[entry.type] - if b then - -- Deployment entries have no fig (params-driven). Others need a - -- live Object - drop them silently if destroyed/invalid. - local needsFig = (entry.type ~= "deployment") - if needsFig and (not entry.fig - or type(entry.fig.getPosition) ~= "function") then - stale[#stale + 1] = key + if not entry.fig or type(entry.fig.getPosition) ~= "function" then + stale[#stale + 1] = key + elseif entry.objGUID == nil then + local ok, obj = pcall(macSpawnProjector, entry.type, entry.fig, entry.params) + if ok and obj then + entry.obj = obj + entry.objGUID = obj.getGUID() else - local ok, out = pcall(b, entry.fig, entry.params) - if ok and out then - for _, l in ipairs(out.lines or {}) do - table.insert(lines, l) - end - for _, d in ipairs(out.decals or {}) do - table.insert(decals, d) - end - else - stale[#stale + 1] = key - end + stale[#stale + 1] = key end + elseif not macIsLive(entry.obj) then + -- The Projector is gone and we did not remove it, so a sweeper + -- did: standbyTokens, removeLockedRulers or Clear Map. Those are + -- explicit "wipe the table" commands, so treat it as the overlay + -- having been switched off rather than respawn behind them. + stale[#stale + 1] = key end end - for _, k in ipairs(stale) do activeOverlays[k] = nil end - Global.setVectorLines(lines) - Global.setDecals(decals) + for _, k in ipairs(stale) do macRemove(k) end +end + +-- Deferred by one frame so a single click touching several entries +-- reconciles once. +function macRedrawAll() + if macRedrawPending then return end + macRedrawPending = true + Wait.frames(function() + macRedrawPending = false + pcall(macRedrawNow) + end, 1) end function gSpawnCohesion(params) local fig = getObjectFromGUID(params.figGUID) if not fig then return end - activeOverlays[fig.getGUID() .. ":cohesion"] = { + -- macRemove first: re-spawning over a live entry would orphan its + -- Projector, which nothing would ever destroy. + local key = fig.getGUID() .. ":cohesion" + macRemove(key) + activeOverlays[key] = { type = "cohesion", fig = fig, params = params or {} } macRedrawAll() @@ -735,8 +349,12 @@ function gClearCohesion(params) local fig = getObjectFromGUID(params.figGUID) if not fig then return end - activeOverlays[fig.getGUID() .. ":cohesion"] = nil - macRedrawAll() + -- Vanilla fig scripts clear cohesion from onPickedUp. Design choice + -- (11 aug): our cohesion stays visible and FOLLOWS the fig during the + -- drag, like Range does, so clears on a held fig are ignored (the + -- vanilla onPickedUp clear is the only caller in that state). + if fig.held_by_color then return end + macRemove(fig.getGUID() .. ":cohesion") end function gToggleCohesion(params) @@ -750,42 +368,28 @@ end end --- Range polling: a slow timer (5 frames = ~12 fps) that re-draws active --- range rulers so they follow figures in real-time. Stops automatically when --- no range overlay is active anymore - avoids the overnight memory leak. -macRangePollingActive = macRangePollingActive or false - -function macRangePoll() - local hasRange = false - for _, e in pairs(activeOverlays) do - if e.type == "range" then hasRange = true; break end - end - if hasRange then - macRedrawAll() - Wait.frames(macRangePoll, 5) - else - macRangePollingActive = false - end -end - function gSpawnRange(params) local fig = getObjectFromGUID(params.figGUID) if not fig then return end - activeOverlays[fig.getGUID() .. ":range"] = { + local key = fig.getGUID() .. ":range" + macRemove(key) + activeOverlays[key] = { type = "range", fig = fig, params = params or {} } macRedrawAll() - if not macRangePollingActive then - macRangePollingActive = true - Wait.frames(macRangePoll, 5) - end end function gClearRange(params) local fig = getObjectFromGUID(params.figGUID) if not fig then return end - activeOverlays[fig.getGUID() .. ":range"] = nil - macRedrawAll() + macRemove(fig.getGUID() .. ":range") + -- Windows mode: also destroy the vanilla bundle this fig owns. It was + -- spawned in the Global scope by gRangeTrigger, so the token's own + -- exitTargetingMode/clearRangeRulers cannot reach it. + if macWinRangeGUID == params.figGUID then + pcall(clearRangeRulersOriginalGlobal) + macWinRangeGUID = nil + end end function gToggleRange(params) @@ -799,33 +403,19 @@ end end -function gSpawnDeployment(params) - -- params: { cell = "r" | "b" | ..., pos = {x, y, z} } - if not params or not params.cell or not params.pos then return end - local key = "deploy:" .. params.cell .. ":" - .. tostring(params.pos[1]) .. ":" .. tostring(params.pos[3]) - activeOverlays[key] = {type = "deployment", fig = nil, params = params} - macRedrawAll() -end - -function gClearAllDeployment() - for k, e in pairs(activeOverlays) do - if e.type == "deployment" then activeOverlays[k] = nil end - end - macRedrawAll() -end - function gSpawnMaxMove(params) local fig = getObjectFromGUID(params.figGUID) if not fig then return end - -- Capture the spawn-time position + yaw. MaxMove is anchored to where - -- the move STARTED - it must not follow the fig as it slides toward - -- the destination. The yaw matters for oblong stadium overlays so the - -- white base ring keeps its orientation while the fig pivots. + -- Capture the spawn-time position and yaw. MaxMove is anchored to where + -- the move STARTED: it must not follow the fig as it slides toward the + -- destination. A Projector tracks its target by default, so this one is + -- deliberately spawned with no follow script (see PROJECTOR_SPEC). local p = (params and params.params) or params or {} p.anchorPos = fig.getPosition() p.anchorRot = fig.getRotation().y - activeOverlays[fig.getGUID() .. ":maxmove"] = { + local key = fig.getGUID() .. ":maxmove" + macRemove(key) + activeOverlays[key] = { type = "maxmove", fig = fig, params = p } macRedrawAll() @@ -834,196 +424,116 @@ function gClearMaxMove(params) local fig = getObjectFromGUID(params.figGUID) if not fig then return end - activeOverlays[fig.getGUID() .. ":maxmove"] = nil - macRedrawAll() -end - -function onObjectPickUp(player_color, obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - local changed = false - for key, entry in pairs(activeOverlays) do - if key:sub(1, plen) == prefix then - -- Cohesion: static, hide during pickup, restore on drop. - -- Range: keep visible; the polling timer redraws each tick so - -- the ruler follows the figure in real-time during the drag. - if entry.type == "cohesion" then - hiddenWhilePickedUp[key] = entry - activeOverlays[key] = nil - changed = true - end - end - end - if changed then macRedrawAll() end + macRemove(fig.getGUID() .. ":maxmove") end -function onObjectDrop(player_color, obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - - -- Defer the overlay restore until the fig has come to rest. Without this, - -- a flick-drop with residual inertia leaves the overlay anchored at the - -- release position while the fig continues to slide across the table. - local function tryRestore() - local fig = getObjectFromGUID(guid) - if not fig then return end -- destroyed during slide - if fig.held_by_color then return end -- picked up again, hold off - local v = fig.getVelocity and fig.getVelocity() - if v then - local speed = math.sqrt((v.x or 0)^2 + (v.y or 0)^2 + (v.z or 0)^2) - if speed > 0.05 then - Wait.frames(tryRestore, 3) - return - end - end - local changed = false - for key, entry in pairs(hiddenWhilePickedUp) do - if key:sub(1, plen) == prefix then - activeOverlays[key] = entry - hiddenWhilePickedUp[key] = nil - changed = true - end - end - if changed then macRedrawAll() end - end - tryRestore() -end +-- Note: no onObjectPickUp/onObjectDrop handlers (the vanilla Global defines +-- none either). Cohesion, like Range, stays visible during a drag and follows +-- because its Projector carries the tracking script; the vanilla onPickedUp +-- clear is neutralized in gClearCohesion while the fig is held. function onObjectDestroy(obj) if not obj or not obj.getGUID then return end local guid = obj.getGUID() local prefix = guid .. ":" local plen = #prefix - local changed = false - for key in pairs(activeOverlays) do - if key:sub(1, plen) == prefix then - activeOverlays[key] = nil - changed = true - end - end - for key in pairs(hiddenWhilePickedUp) do - if key:sub(1, plen) == prefix then - hiddenWhilePickedUp[key] = nil + local doomed = {} + for key, entry in pairs(activeOverlays) do + -- The destroyed object is either the figure an overlay belongs to, + -- or the overlay's own Projector (a sweeper, or a player deleting it + -- by hand). + if key:sub(1, plen) == prefix or entry.objGUID == guid then + doomed[#doomed + 1] = key end end - if changed then macRedrawAll() end + for _, key in ipairs(doomed) do macRemove(key) end end --- Texture preload is handled by macRedrawAll which always includes all PNG --- URLs as invisible decals far below the table. No separate preload needed. - -- ============================================ --- PER-SEAT MODE TOGGLE (Cohesion + Range) --- Each seated player picks their renderer: "mac" (this patch) or --- "windows" (original bundle Projector). When a player triggers an overlay, --- the router checks THEIR mode and uses that renderer. The rendered overlay --- is visible to everyone (TTS engine limitation). +-- TABLE-WIDE OVERLAY TOGGLE (Range + Cohesion + MaxMove) +-- One switch for the whole table: "windows" (the mod's original Projectors) +-- or "mac" (the Iron Squadron overlays). Per-seat modes were dropped on +-- purpose: any rendered overlay is visible to every player (TTS engine +-- limitation), so one player triggering an original Projector shows it to +-- the whole table anyway. The switch is the Iron Squadron button in the +-- bottom-right menu: default grey = off (originals, untouched), green = on. -- ============================================ -playerOverlayMode = playerOverlayMode or {} -- color -> "mac" | "windows" --- Default = "windows" (majority of TTS users). Mac users toggle their seat --- via the floating panel. Same for deployment (table-wide setting). -deploymentMode = deploymentMode or "windows" - -function gGetMode(params) - return playerOverlayMode[params.color] or "windows" -end - -function gGetDeploymentMode() - return deploymentMode -end - -function gToggleDeploymentMode() - deploymentMode = (deploymentMode == "mac") and "windows" or "mac" - -- Wipe any active deployment zones so the next setup uses the new mode. - gClearAllDeployment() - macDeferRefresh() -end - --- Replicates the original showRangeOnHoveredModel toggle, calling the --- aliased Global originals so our Mac overrides don't recapture the path. -function gWindowsRangeToggle(fig) - if not fig then return end - if rangeRuler ~= nil then - pcall(clearRangeRulersOriginalGlobal) - if selectedUnitObj == fig then - selectedUnitObj = nil - return - end - end - if fig.interactable then - pcall(function() spawnRangeRulerOriginalGlobal(fig) end) - selectedUnitObj = fig - end -end +overlayMode = overlayMode or "windows" -- "windows" | "mac", table-wide --- Replicates the original showCohesionOnHoveredModel toggle. Uses Global --- aliased originals + Global cohesionRuler state (set by the original). -function gWindowsCohesionToggle(fig) - if not fig then return end - if fig.interactable and selectedUnitObj == fig and cohesionRuler ~= nil then - pcall(clearCohesionRulerOriginalGlobal) - selectedUnitObj = nil - return - end - pcall(clearCohesionRulerOriginalGlobal) - pcall(function() spawnCohesionRulerOriginalGlobal(fig) end) - selectedUnitObj = fig -end +function gGetMode(_) return overlayMode end function gCohesionTrigger(params) if not params or not params.figGUID then return end local fig = getObjectFromGUID(params.figGUID) if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "windows" - if mode == "windows" then - gWindowsCohesionToggle(fig) - else + if overlayMode ~= "windows" then + gToggleCohesion({figGUID = params.figGUID}) + return + end + -- Windows mode: the vanilla Projector lives in the FIG scope (every fig + -- requires !/Cohesion), so read and clear it there. Vanilla + -- spawnCohesionRuler RESPAWNS instead of toggling, so without this a + -- second click just redrew the ruler and it could never be turned off. + -- pcall guards objects that carry no such function (e.g. a hovered + -- non-fig object), which fall back to the Mac renderer. + local ok, isOn = pcall(function() return fig.getVar("cohesionRuler") ~= nil end) + if not ok then + gToggleCohesion({figGUID = params.figGUID}) + return + end + if isOn then + pcall(function() fig.call("clearCohesionRulerOriginal", fig) end) + elseif not pcall(function() fig.call("spawnCohesionRulerOriginal", fig) end) then gToggleCohesion({figGUID = params.figGUID}) end end +-- Which fig currently owns the vanilla (Windows-mode) Range bundle, so a +-- second trigger on the same fig turns it off like vanilla +-- showRangeOnHoveredModel does. +macWinRangeGUID = macWinRangeGUID or nil + function gRangeTrigger(params) if not params or not params.figGUID then return end local fig = getObjectFromGUID(params.figGUID) if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "windows" - if mode == "windows" then - gWindowsRangeToggle(fig) - else - gToggleRange({ - figGUID = params.figGUID, - forceFigMode = params.forceFigMode, - rangeKey = params.rangeKey, - }) - end -end - --- UI: floating panel with one toggle button per seated player -function macModeClick(player, _, id) - local color = id:sub(9) -- "macmode_Red" -> "Red" - if player.color ~= color then - broadcastToColor("Only the player at this seat can toggle their mode.", - player.color, {1, 0.5, 0.5}) + if overlayMode ~= "windows" then + -- Forward the WHOLE params table. Rebuilding it with figGUID alone + -- dropped forceFigMode, which the hover hotkey sets and + -- macResolveBundle reads: the fig-leader bands documented for a + -- hovered token never rendered, its own single ring did instead. + -- showRangeOnHoveredModel already forwards it, so the same gesture + -- gave two different results depending on which path it took. + gToggleRange(params) return end - local cur = playerOverlayMode[color] or "windows" - playerOverlayMode[color] = (cur == "mac") and "windows" or "mac" - -- Wipe Mac-side overlays so stale visuals don't linger after the toggle. - -- We don't track per-color ownership, so this clears Cohesion+Range for - -- everyone - acceptable since each player can re-trigger their hotkey. - -- Windows-side overlays (fig-scoped cohesionRuler/rangeRuler) are - -- cleared by iterating objects with active state. - for k, e in pairs(activeOverlays) do - if e.type == "cohesion" or e.type == "range" then - activeOverlays[k] = nil + -- The vanilla bundle Range lives in the GLOBAL scope: !/RangeRulers is + -- required by Global, while figs only require !/Cohesion. Calling + -- fig.call("spawnRangeRulerOriginal", fig) therefore ALWAYS raised + -- "no such function"; the pcall swallowed it and we fell through to the + -- Mac renderer, so Windows mode silently drew Mac overlays instead of + -- the original Projector. Route through the Global aliases captured at + -- the top of this block instead. + local wasOn = (macWinRangeGUID == params.figGUID) + pcall(clearRangeRulersOriginalGlobal) + macWinRangeGUID = nil + if not wasOn then + if pcall(function() spawnRangeRulerOriginalGlobal(fig) end) then + macWinRangeGUID = params.figGUID + else + gToggleRange(params) end end - macRedrawAll() +end + +function macModeToggle(_, _, _) + overlayMode = (overlayMode == "mac") and "windows" or "mac" + -- Wipe both renderers' overlays so stale visuals don't linger after the + -- toggle; each player just re-triggers their hotkey. + macRemoveAllOfType("cohesion") + macRemoveAllOfType("range") + macRemoveAllOfType("maxmove") for _, obj in ipairs(getAllObjects()) do if obj.getVar and obj.getVar("cohesionRuler") then pcall(function() obj.call("clearCohesionRulerOriginal", obj) end) @@ -1032,6 +542,14 @@ pcall(function() obj.call("clearRangeRulersOriginal", obj) end) end end + -- The Windows-mode Range bundle is spawned from the Global scope, which + -- getAllObjects() above does not cover. + pcall(clearRangeRulersOriginalGlobal) + macWinRangeGUID = nil + broadcastToAll( + (overlayMode == "mac") and "Iron Squadron overlays ON for the whole table." + or "Iron Squadron overlays OFF: the mod's original overlays are back.", + (overlayMode == "mac") and {0.55, 0.9, 0.6} or {0.9, 0.75, 0.55}) macDeferRefresh() end @@ -1046,215 +564,79 @@ return nil, nil end --- Tracks the panel's desired active state across refreshes. Persists so a --- click on the close X before the initial refresh is honored. Hidden at first --- load (default = false); the floating "Mac Patch" menu button reveals it. -macModePanelActive = (macModePanelActive == nil) and false or macModePanelActive - -function macModeToggleVisibility() - macModePanelActive = not macModePanelActive - -- Defer the XML rebuild: TTS occasionally throws a UTF-8 byte-buffer - -- encoding error if UI.setXmlTable runs synchronously inside a UI - -- click handler. Deferring one frame + pcall has been reliable. - Wait.frames(function() - pcall(function() - local tree = UI.getXmlTable() or {} - local panel = macFindNodeById(tree, "macModePanel") - if panel then - panel.attributes.active = macModePanelActive and "true" or "false" - UI.setXmlTable(tree) - else - macRefreshModeUI() - end - end) - end, 1) -end - +-- UI: single Iron Squadron toggle button in the bottom-right +-- legionFloatingMenu. function macRefreshModeUI() - local seated = Player.getPlayers() - local rows = {{ - tag = "Panel", - attributes = { - color = "transparent", - preferredHeight = "24", - }, - children = { - { - tag = "Text", - attributes = { - text = "Mac TTS U6 Patch", - fontSize = "16", - color = "white", - alignment = "MiddleLeft", - rectAlignment = "MiddleLeft", - }, - }, - { - tag = "Button", - attributes = { - id = "macModePanelClose", - text = "X", - onClick = "macModeToggleVisibility", - color = "#3a1e1e", - textColor = "white", - fontSize = "12", - width = "24", - height = "20", - rectAlignment = "MiddleRight", - }, - }, - }, - }, { - tag = "Text", - attributes = { - text = "Cohesion & Range: pick your renderer", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }} - if #seated == 0 then - table.insert(rows, { - tag = "Text", - attributes = { - text = "(no seated players)", - fontSize = "12", - color = "#888888", - alignment = "MiddleCenter", - }, - }) - end - for _, p in ipairs(seated) do - local mode = playerOverlayMode[p.color] or "windows" - local label = p.color .. " - " .. - (mode == "mac" and "MAC FALLBACK" or "WINDOWS ORIGINAL") - local bg = (mode == "mac") and "#1e7a3a" or "#7a3a1e" - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_" .. p.color, - text = label, - onClick = "macModeClick", - color = bg, - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - end - -- Deployment is table-wide (everyone sees the same zones); use a single - -- global toggle rather than per-seat. - table.insert(rows, { - tag = "Text", - attributes = { - text = "Deployment (table-wide)", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }) - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_deployment", - text = (deploymentMode == "mac") and "DEPLOYMENT: MAC FALLBACK" - or "DEPLOYMENT: WINDOWS ORIGINAL", - onClick = "macDeploymentClick", - color = (deploymentMode == "mac") and "#1e3a7a" or "#7a3a1e", - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - -- Build my panel (will be merged into the existing UI tree below so we - -- don't clobber legionFloatingMenu / Welcome / Chess Clocks etc.). - local panel = { - tag = "Panel", - attributes = { - id = "macModePanel", - active = macModePanelActive and "true" or "false", - rectAlignment = "MiddleRight", - offsetXY = "-10 80", - width = "260", - height = "320", - color = "rgba(0.06,0.06,0.06,0.9)", - padding = "8 8 8 8", - outlineSize = "1 1", - outline = "#303030", - }, - children = {{ - tag = "VerticalLayout", - attributes = { - spacing = "4", - childForceExpandHeight = "false", - childForceExpandWidth = "true", - }, - children = rows, - }}, - } - local tree = UI.getXmlTable() or {} - -- Remove any stale macModePanel before reinserting the fresh one. + -- Drop the legacy mode-picker panel if this save still carries one. for i = #tree, 1, -1 do if tree[i].attributes and tree[i].attributes.id == "macModePanel" then table.remove(tree, i) end end - table.insert(tree, panel) - - -- Inject a "Mac Patch" button into the bottom-right legionFloatingMenu - -- (replaces the first interactable=false placeholder button). local menu = macFindNodeById(tree, "legionFloatingMenu") if menu and menu.children then - local hasMine = false - for _, c in ipairs(menu.children) do + local isMac = (overlayMode == "mac") + local attrs = { + id = "macModeMenuButton", + onClick = "macModeToggle", + tooltip = isMac + and "Iron Squadron overlays: ON for the whole table. Click to go back to the mod's original overlays." + or "Iron Squadron overlays: OFF, the mod's original overlays are in use. Click to turn them on for the whole table.", + } + -- OFF keeps the sibling buttons' default light-grey look (Welcome, + -- Chess Clocks); ON switches to green. + if isMac then attrs.color = "#1e7a3a" end + local button = { + tag = "Button", + attributes = attrs, + children = {{ + -- White PNG sprite (CustomUIAssets "isqLogo"), tinted per + -- state: dark on the grey button, white on green. The shape + -- lives in the alpha channel, which is what makes the tint + -- work. + tag = "Image", + attributes = { + image = "isqLogo", + color = isMac and "#FFFFFF" or "#2b2b2b", + preserveAspect = "true", + raycastTarget = "false", + }, + }}, + } + local placed = false + for i, c in ipairs(menu.children) do if c.attributes and c.attributes.id == "macModeMenuButton" then - hasMine = true; break + menu.children[i] = button + placed = true + break end end - if not hasMine then + if not placed then for i, c in ipairs(menu.children) do if c.attributes and c.attributes.interactable == "false" then - menu.children[i] = { - tag = "Button", - attributes = { - id = "macModeMenuButton", - fontSize = "10", - onClick = "macModeToggleVisibility", - tooltip = "Toggle Mac TTS U6 Patch panel", - }, - value = "Mac Patch", - } + menu.children[i] = button + placed = true break end end end end - UI.setXmlTable(tree) end -function macDeploymentClick(_, _, _) - gToggleDeploymentMode() -end - -- Defer one frame: TTS throws a UTF-8 byte-buffer encoding error if we --- rebuild the UI XML synchronously inside a click handler / seat change. --- pcall guards against any residual race. Promoted to a global function --- so macModeClick / macModeToggleVisibility / gToggleDeploymentMode (all --- defined before this point) can call it via name lookup at call time. +-- rebuild the UI XML synchronously inside a click handler. pcall guards +-- against any residual race. function macDeferRefresh() Wait.frames(function() pcall(macRefreshModeUI) end, 1) end -function onPlayerChangeColor(_) macDeferRefresh() end -function onPlayerConnect(_) macDeferRefresh() end -function onPlayerDisconnect(_) macDeferRefresh() end -- Initial UI build, deferred + pcalled like the rest. Wait.time(function() pcall(macRefreshModeUI) end, 2) -- Override hotkey init functions to capture playerColor and route through --- the per-seat trigger. Defining initCohesionHotkeys/initRangebandHotkeys +-- the mode router. Defining initCohesionHotkeys/initRangebandHotkeys -- here SHADOWS the originals - when the original onLoad runs init*(), our -- versions register the hotkey instead. @@ -1409,7 +791,7 @@ -- also fires when changeSpeed2/3 calls moveUnit() with no isDeploy arg. if isDeploy ~= true then if maxMoveTemplateBundleToSpawn ~= nil then - local _macMode = Global.call("gGetMode", {color = macActivePlayerForMove}) + local _macMode = Global.call("gGetMode", {}) if _macMode == "windows" then -- WINDOWS ORIGINAL: spawn Custom_AssetBundle Projector maxMoveTemplate = spawnObject({ @@ -1426,11 +808,14 @@ maxMoveTemplate.use_gravity = false maxMoveTemplate.setName("Maximum Move") else - -- MAC FALLBACK: route through the Global Overlays manager + -- IRON SQUADRON: route through the Global Overlays manager. + -- The bundle travels with the call: getMovementLinks() is + -- required by this object, not by Global. Global.call("gSpawnMaxMove", { figGUID = selectedUnitObj.getGUID(), baseSize = unitData.baseSize, speed = unitData.selectedSpeed, + bundle = maxMoveTemplateBundleToSpawn, }) maxMoveTemplate = nil end @@ -1469,110 +854,35 @@ maxMoveTemplate = nil end""" -# Deployment Boundary: replace the Custom_AssetBundle Projector spawn inside -# spawnBoundaryCell (SETUP_CONTROLLER 1cb552) with a Global.call to the manager. -# Idempotent: matches both the vanilla form AND a previously-injected patched -# form (between the MAC PATCH dual-path marker and the closing ` end`). The -# patched alternative is listed first so re-runs prefer it. -DEPLOYMENT_SPAWN_RE = re.compile( - r"(?:" - r"-- MAC PATCH dual-path:.*?\r?\n end" - r"|" - r"local projector = spawnObject\(\{\r?\n" - r" type = \"Custom_AssetBundle\",.*?" - r"projector\.setCustomObject\(\{\r?\n" - r" assetbundle = asset,\r?\n" - r" \}\)" - r")", - re.DOTALL -) - -DEPLOYMENT_SPAWN_REPLACEMENT = r"""-- MAC PATCH dual-path: branch on the table-wide deploymentMode toggle. - local _macDeployMode = Global.call("gGetDeploymentMode") - local projector = nil - if _macDeployMode == "windows" then - projector = spawnObject({ - type = "Custom_AssetBundle", - position = pos, - scale = {0, 0, 0}, - rotation = {0, deployRotations[cell], 0} - }) - projector.setName("Deployment Boundary") - projector.setLock(true) - projector.setCustomObject({ - assetbundle = asset, - }) - else - Global.call("gSpawnDeployment", { cell = cell, pos = pos }) - end""" -# clearDeploymentBoundary: also clear manager entries. -CLEAR_DEPLOYMENT_RE = re.compile( - r"function clearDeploymentBoundary\(\)\r?\n" - r" local battlefieldObjs = battlefieldZone\.getObjects\(\).*?\r?\n" - r" end\r?\nend", - re.DOTALL +# SIL/LCK button placement on oblong bases (upstream bug, both OSes): the +# vanilla offset is baseRadius/2 + 0.1 with a single per-size radius and no +# axis handling, so on long/snail bases the buttons land on top of the +# model. Raise the offset to the model's actual half-depth when bigger. +BUTTON_OFFSET_RE = re.compile( + r"(local buttonOffset = calculateButtonZOffset\(templateInfo\.baseRadius\[unitData\.baseSize\]\))" + r"(?! -- MAC PATCH)" ) - -CLEAR_DEPLOYMENT_REPLACEMENT = r"""function clearDeploymentBoundary() - -- MAC PATCH: clear Global Overlays manager entries first. - Global.call("gClearAllDeployment", {}) - local battlefieldObjs = battlefieldZone.getObjects() - for _, obj in pairs(battlefieldObjs) do - if obj.getName() == "Deployment Boundary" then - destroyObject(obj) +BUTTON_OFFSET_REPLACEMENT = r"""\1 -- MAC PATCH oblong + -- Deferred: at onLoad the custom mesh is not loaded yet, so bounds read + -- as zero. Re-place SIL/LCK once the model is in, oblong bases only. + if unitData and (unitData.baseSize == "long" or unitData.baseSize == "snail") then + Wait.time(function() + if self == nil then return end + local okB, b = pcall(function() return self.getBoundsNormalized() end) + local okS, sc = pcall(function() return self.getScale() end) + if not (okB and okS and b and sc and sc.z ~= 0) then return end + local half = (b.size.z / sc.z) * 0.5 + 0.15 + for _, btn in ipairs(self.getButtons() or {}) do + if (btn.label == "SIL" or btn.label == "LCK") + and (btn.position.z or 0) < half then + self.editButton({index = btn.index, + position = {btn.position.x, btn.position.y, half}}) end - end -end""" + end + end, 3) + end""" -# Silhouette URL swap to our Unity-6-compiled bundle. -# -# The upstream BucketheadBits_Silhouette bundle uses Allen White's custom -# Silhouette shader compiled under Unity 2019.4. TTS U6 on Mac fails to -# load that shader at runtime (console: "Shader didn't load correctly for -# AssetBundle material BucketheadBits_Silhouette. Assigning Standard -# shader.") and falls back to Standard, which renders the cylinder opaque. -# -# Our fork-hosted bundle uses the SAME prefab + material + shader source -# (Assets/Sihl/), only the compile target differs (Unity 6 instead of -# Unity 2019.4). Path A from Dicewrench's analysis. -# -# We swap ALL silhouette URLs (default cylinder, snail, long) to the same -# fork URL. Snail/long visual fidelity is acceptable for now (cylinder -# placeholder); per-base meshes are a separate follow-up. -SILHOUETTE_BUNDLE_URL = ( - "https://raw.githubusercontent.com/ironsquadronfr-hub/tts/" - "mac-projector-fallback/mod/data/mac-fallback-assets/" - "silhouette_mac_fallback.unity3d" -) -SILHOUETTE_URL_RE = re.compile( - r'(silhouetteData\s*=\s*)"https?://[^"]*/ugc/[^"]+"' -) -SILHOUETTE_URL_REPLACEMENT = r'\1"' + SILHOUETTE_BUNDLE_URL + '"' - - -# Cleanup of debug prints and corrupted signatures from earlier sessions. -# Safe no-op on a clean save. Matches both the legacy `print("[SIL DEBUG] ...)` -# form AND the chat-visible `printToAll("[SIL] ...", {...})` form. -SIL_DEBUG_STRIP_RE = re.compile( - # Match leading horizontal whitespace only (NOT \s* which would also eat - # the newline before, mashing the function signature against the next - # statement - that was a real bug we hit earlier this session). - r'[ \t]*(?:print\("\[SIL DEBUG\][^\n]*\)|' - r'printToAll\("\[SIL\][^"]*",\s*\{[^}]*\}\))\r?\n' -) -# Repair function signatures that were mashed by the previous \s*-glob bug. -# Idempotent: target the specific mashed patterns; matches are zero on a -# clean save. -SIL_REPAIR_TOGGLE_RE = re.compile( - r'(function toggleSilhouettes\(\))( if silhouetteState then)' -) -SIL_REPAIR_SHOW_RE = re.compile( - r'(function showSilhouette\(\))( for k, guid in pairs\(miniGUIDs)' -) -SIL_REPAIR_SPAWN_RE = re.compile( - r'(function spawnSilhouette\(obj, pos, rot\))( local globals)' -) # Silhouette state-desync fix (upstream bug). @@ -1620,23 +930,29 @@ ONLOAD_OPEN_RE = re.compile(r"function onload\(\)\r?\n") -# SIL button rename (root cause: TTS engine intercepts the click_function -# name "toggleSilhouettes" silently in Mac mode; LCK/R buttons unaffected -# because their click_function names differ. Confirmed empirically 17 mai -# 2026: renaming to "macToggleSil" + forwarder unblocks Mac mode entirely). +# SIL button rename. On macOS the click_function name "toggleSilhouettes" +# was silently swallowed by TTS: the button played its sound and animation +# but the function was never called, while LCK and R (different names) kept +# working. Renaming it + forwarding unblocked it, confirmed empirically on +# 17 may 2026. # -# We swap the click_function name on the SIL button definition AND inject a -# forwarder function that simply calls toggleSilhouettes(). The original -# toggleSilhouettes is untouched, so Windows mode behavior is identical. +# KEPT even though silhouettes left the toggle's scope (12 aug). Reverting +# our silhouette code to vanilla back then did NOT lift the mute, so the +# leading suspect is a side effect of the Global patch itself, which we do +# still inject. Behaviour is unchanged either way: the button calls the +# untouched vanilla toggleSilhouettes through a one-line forwarder. Worth a +# single in-game click to find out whether it can go: patch a save without +# it and press SIL on a Unit Leader. # Idempotent: re-runs detect both markers and skip. SIL_BUTTON_CLICKFN_OLD = 'click_function = "toggleSilhouettes"' SIL_BUTTON_CLICKFN_NEW = 'click_function = "macToggleSil"' SIL_FORWARDER = ''' --- MAC PATCH: rebuilt SIL button click handler. The original click_function --- name "toggleSilhouettes" is silently intercepted by TTS in Mac mode (the --- SIL button plays its click sound/animation but the function is never --- called). Renaming the click_function on the button definition + adding --- this forwarder restores the routing. +-- MAC PATCH: rebuilt SIL button click handler. On macOS the original +-- click_function name "toggleSilhouettes" is silently intercepted by TTS +-- (the SIL button plays its click sound and animation but the function is +-- never called). Renaming the click_function on the button definition and +-- adding this forwarder restores the routing. The silhouette code itself is +-- vanilla. function macToggleSil() toggleSilhouettes() end @@ -1657,7 +973,7 @@ -- (magenta on Mac, native on Windows) until a per-token Mac fallback -- ships - see TOKEN_BUTTON_WRAPPER for the per-token override path. -- --- The single alias below is REQUIRED: Global macModeClick calls +-- The single alias below is REQUIRED: Global macModeToggle calls -- obj.call("clearRangeRulersOriginal", obj) on every object holding a -- rangeRuler state, to wipe the vanilla ruler when toggling modes. clearRangeRulersOriginal = clearRangeRulers @@ -1670,7 +986,11 @@ ORDER_TOKEN_BUTTON_OVERRIDES = r""" -- MAC PATCH per-seat router (Order_Token button click overrides) function toggleCohesionRuler(_, playerColor) + if not selectedUnitObj then return end if not rulerOn then + -- Deterministic ON: gCohesionTrigger toggles by GUID, so clear any + -- stale Mac overlay first (e.g., drawn via the hover hotkey). + Global.call("gClearCohesion", { figGUID = selectedUnitObj.getGUID() }) Global.call("gCohesionTrigger", { figGUID = selectedUnitObj.getGUID(), playerColor = playerColor, @@ -1682,10 +1002,73 @@ end end +-- ⏳ CORRECTIF D'ATTENTE, 14 aout. La chaine d'attaque n'a jamais ete routee : +-- attackMode() appelle le spawnRangeRuler vanilla du scope de l'Order Token, +-- donc le mode Iron Squadron recevait quand meme la regle du mod, et aucune des +-- sorties de ce chemin ne touchait activeOverlays -- RANGE puis ATTACK laissait +-- deux regles a l'ecran. +-- +-- Arbitrage Martin : la fonctionnalite date de la V1 du jeu et n'est plus juste +-- en V2. Ceci la branche en attendant qu'un outil V2 la remplace ; ce n'est pas +-- une conception, c'est un bouchon. Le garder ennuyeux. +-- Spec : section "Chaine d'attaque" du cahier des charges Iron Squadron. +-- +-- Defini ici et pas dans le bloc Cohesion parce que ce bloc-ci est ajoute EN +-- DERNIER, donc c'est lui qui gagne. Le local vient avant les fonctions qui le +-- capturent. +local function macClearSelectedRange() + if selectedUnitObj then + Global.call("gClearRange", { figGUID = selectedUnitObj.getGUID() }) + end +end + +function attackMode() + if not attackModeOn then + exitTargetingMode() + highlightEnemies() + if selectedUnitObj then + Global.call("gClearRange", { figGUID = selectedUnitObj.getGUID() }) + Global.call("gRangeTrigger", { figGUID = selectedUnitObj.getGUID() }) + end + attackModeOn = true + resetTargetingButtons() + else + exitTargetingMode() + end +end + +function exitTargetingMode() + enemyHighlighted = false + attackModeOn = false + macClearSelectedRange() + pcall(clearRangeRulers) + pcall(unhighlightEnemies) + pcall(clearAttackLine) +end + +function exitAttackMode() + enemyHighlighted = false + attackModeOn = false + macClearSelectedRange() + pcall(clearRangeRulers) + pcall(unhighlightEnemies) +end + +function clearTemplates() + pcall(clearMovementTemplates) + macClearSelectedRange() + pcall(clearRangeRulers) + pcall(clearCohesionRulers) +end + function targetingMode(_, playerColor) + if not selectedUnitObj then return end if not enemyHighlighted then exitAttackMode() highlightEnemies() + -- Deterministic ON: gRangeTrigger toggles by GUID, so clear any + -- stale Mac overlay first to guarantee this click draws. + Global.call("gClearRange", { figGUID = selectedUnitObj.getGUID() }) Global.call("gRangeTrigger", { figGUID = selectedUnitObj.getGUID(), playerColor = playerColor, @@ -1693,14 +1076,13 @@ enemyHighlighted = true resetRangeButtons() else + -- exitTargetingMode/clearRangeRulers only clears the vanilla bundle + -- ruler; clear the Mac overlay too so OFF really hides the rings. + Global.call("gClearRange", { figGUID = selectedUnitObj.getGUID() }) exitTargetingMode() end end --- Capture playerColor for Maximum Move per-seat routing. moveUnit() doesn't --- receive the click color, so each entry-point button stashes it in a --- script-global var that the spawn block reads. Defaults to nil -> Mac mode. - -- initMove / initDeploy: redefine the body INLINE rather than wrap, because -- wrapping (capturing the original via a local + calling it) reproducibly -- broke every Order Token button click in testing (root cause unknown; @@ -1709,7 +1091,6 @@ -- capture so the MAXMOVE_SPAWN block can route to Mac/Windows per seat. function initMove(obj, playerColor) if not selectedUnitObj then return end - macActivePlayerForMove = playerColor initPos = selectedUnitObj.getPosition() initRot = selectedUnitObj.getRotation() selectedUnitObj.call("setStartPos") @@ -1717,7 +1098,6 @@ end function initDeploy(obj, playerColor) if not selectedUnitObj then return end - macActivePlayerForMove = playerColor initPos = selectedUnitObj.getPosition() initRot = selectedUnitObj.getRotation() selectedUnitObj.call("setStartPos") @@ -1733,28 +1113,24 @@ -- Tokens (helper fns setTemplateVariables/clearTemplates/moveUnit exist -- on both because they're part of the include set the List Builder emits). function changeSpeed1(_, playerColor) - macActivePlayerForMove = playerColor unitData.selectedSpeed = 1 setTemplateVariables() clearTemplates() moveUnit() end function changeSpeed2(_, playerColor) - macActivePlayerForMove = playerColor unitData.selectedSpeed = 2 setTemplateVariables() clearTemplates() moveUnit() end function changeSpeed3(_, playerColor) - macActivePlayerForMove = playerColor unitData.selectedSpeed = 3 setTemplateVariables() clearTemplates() moveUnit() end function moveForward(_, playerColor) - macActivePlayerForMove = playerColor self.editButton({ index = 11, click_function = "moveBackwards", label = "B", tooltip = "Move Backwards" @@ -1763,7 +1139,6 @@ moveUnit() end function moveBackwards(_, playerColor) - macActivePlayerForMove = playerColor self.editButton({ index = 11, click_function = "moveForward", label = "F", tooltip = "Move Forward" @@ -1772,12 +1147,10 @@ moveUnit() end function moveLeft(_, playerColor) - macActivePlayerForMove = playerColor moveDirection = "left" moveUnit() end function moveRight(_, playerColor) - macActivePlayerForMove = playerColor moveDirection = "right" moveUnit() end @@ -1810,6 +1183,12 @@ Global.call("gClearRange", { figGUID = self.getGUID() }) rangeOn = false else + -- Clear BEFORE triggering, same rule as the Order Token's COHESION + -- and RANGE buttons. gRangeTrigger toggles by GUID, and the hover + -- hotkey writes to the very same key on this token, so without this + -- the first click on R would turn that overlay OFF while we set + -- rangeOn = true, leaving the button inverted from then on. + Global.call("gClearRange", { figGUID = self.getGUID() }) Global.call("gRangeTrigger", { figGUID = self.getGUID(), playerColor = playerColor, @@ -1926,12 +1305,12 @@ def patch_object_scripts(data: dict) -> tuple: Range block: on every object containing the !/RangeRulers include (Order_Token, Tokens, POI, bomb_cart, etc. - ~20 objects). - Returns (n_cohesion_patched, n_range_patched, n_dep_patched). + Returns (n_cohesion_patched, n_range_patched). """ - n_coh, n_rng, n_dep = 0, 0, 0 + n_coh, n_rng = 0, 0 def walk(o): - nonlocal n_coh, n_rng, n_dep + nonlocal n_coh, n_rng if isinstance(o, dict): guid = o.get("GUID", "").lower() if "LuaScript" in o: @@ -1946,36 +1325,19 @@ def walk(o): ls = new_ls changed = True - # Cleanup: strip stale debug prints + repair any signatures - # mashed by an earlier (overzealous) strip pass. Both are - # no-ops on a clean save. - new_ls, n = SIL_DEBUG_STRIP_RE.subn("", ls) - if n > 0: - ls = new_ls - changed = True - for rx in (SIL_REPAIR_TOGGLE_RE, SIL_REPAIR_SHOW_RE, SIL_REPAIR_SPAWN_RE): - new_ls, n = rx.subn(r'\1\n\2', ls) - if n > 0: - ls = new_ls - changed = True - - # (Diagnostic SIL prints removed 17 mai 2026 once mute Mac - # mode confirmed. SIL_DEBUG_STRIP_RE above stays as a cleanup - # pass for older saves that still carry the prints.) - - # Silhouette URL swap to our Unity-6-compiled bundle. - # Applies to any object that defines spawnSilhouette - # (Unit_Leader 99f1c8, Bomb Cart b497e1, POI Token 761483). - # Idempotent: the regex only matches Steam UGC URLs (/ugc/), - # not our fork URL, so re-runs are no-ops. - new_ls, n = SILHOUETTE_URL_RE.subn(SILHOUETTE_URL_REPLACEMENT, ls) + # Silhouettes are OUT of the toggle's scope (12 aug): their + # bundles are repaired, so vanilla renders them correctly on + # both platforms and the Lua fallback is gone. What stays + # below are fixes to upstream bugs that happen to live on the + # same objects, none of which branch on the mode. + new_ls, n = BUTTON_OFFSET_RE.subn(BUTTON_OFFSET_REPLACEMENT, ls) if n > 0: ls = new_ls changed = True - # SIL button rename + forwarder. Bypasses the TTS engine - # bug where the click_function name "toggleSilhouettes" is - # silently muted in Mac mode. Idempotent: replace() is no-op + # SIL button rename + forwarder. Bypasses the TTS engine bug + # where the click_function name "toggleSilhouettes" is + # silently muted on macOS. Idempotent: replace() is no-op # once swap is done, marker check prevents double-injection. if SIL_BUTTON_CLICKFN_OLD in ls: ls = ls.replace(SIL_BUTTON_CLICKFN_OLD, SIL_BUTTON_CLICKFN_NEW) @@ -2060,18 +1422,6 @@ def rng_repl(m): ls = ls + TOKEN_BUTTON_WRAPPER changed = True - # Deployment Boundary (only in SETUP_CONTROLLER 1cb552) - Mac only - if guid == "1cb552": - new_ls, n = DEPLOYMENT_SPAWN_RE.subn(DEPLOYMENT_SPAWN_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - n_dep += 1 - changed = True - new_ls, n = CLEAR_DEPLOYMENT_RE.subn(CLEAR_DEPLOYMENT_REPLACEMENT, ls, count=1) - if n > 0: - ls = new_ls - changed = True - if changed: o["LuaScript"] = ls @@ -2082,7 +1432,14 @@ def rng_repl(m): walk(x) walk(data) - return n_coh, n_rng, n_dep + return n_coh, n_rng + + +ASSETS_BASE_URL = ("https://raw.githubusercontent.com/ironsquadronfr-hub/tts/" + "mac-projector-fallback/mod/data/mac-fallback-assets/") +# Flip to https://raw.githubusercontent.com/swlegion/tts/main/mod/data/ +# mac-fallback-assets/ right before upstream merge, together with the Lua +# ASSETS_BASE copies (Overlays.ttslua + the inline copy in this file). def main(): @@ -2098,15 +1455,33 @@ def main(): with src.open("r") as f: data = json.load(f) - # Rename so the patched save is recognizable in TTS load list. - data["SaveName"] = "SWL BETA - MAC PATCH" + # Leave SaveName alone: the working save is regenerated from its clean + # backup on every iteration, and a "[MAC PATCH]" suffix reappeared in the + # TTS load list each time. Strip a suffix left by an earlier run. + name = data.get("SaveName") or "" + if name.endswith("[MAC PATCH]"): + data["SaveName"] = name[: -len("[MAC PATCH]")].rstrip() + + # Register the Iron Squadron UI sprite for the toggle button (idempotent + # by Name; macAppleLogo is the retired name and is dropped on the way). + assets = [a for a in (data.get("CustomUIAssets") or []) + if a.get("Name") not in ("isqLogo", "macAppleLogo")] + assets.append({"Type": 0, "Name": "isqLogo", + "URL": ASSETS_BASE_URL + "iron_squadron_logo_v2.png"}) + data["CustomUIAssets"] = assets # 1. Append manager + handlers to Global LuaScript (idempotent: remove # any previous Mac patch block first, then append fresh). original_global_len = len(data.get("LuaScript", "")) existing = data.get("LuaScript", "") cleaned, n_removed = GLOBAL_PATCH_MARKER_RE.subn("", existing) - data["LuaScript"] = cleaned + GLOBAL_PATCH_LUA + # One source of truth for where our own assets live: the Lua block carries + # a placeholder, filled here from ASSETS_BASE_URL. Flipping to upstream + # before merge is then a one-line change. + global_block = GLOBAL_PATCH_LUA.replace("__ISQ_ASSETS_BASE__", ASSETS_BASE_URL) + if "__ISQ_ASSETS_BASE__" in global_block: + sys.exit("ASSETS_BASE placeholder left unfilled in the Global block") + data["LuaScript"] = cleaned + global_block new_global_len = len(data["LuaScript"]) note = " (replaced existing patch)" if n_removed else "" print(f"Global LuaScript: {original_global_len} -> {new_global_len} bytes{note}") @@ -2114,10 +1489,9 @@ def main(): # 2. Replace Cohesion + Range + Deployment blocks in object scripts. print("Object script patches:") - n_coh, n_rng, n_dep = patch_object_scripts(data) + n_coh, n_rng = patch_object_scripts(data) print(f" Cohesion blocks replaced: {n_coh}") print(f" Range blocks replaced: {n_rng}") - print(f" Deployment blocks replaced: {n_dep}") print() dst.parent.mkdir(parents=True, exist_ok=True) @@ -2128,33 +1502,6 @@ def main(): print(f" Save Name: {data.get('SaveName', '?').strip()}") print(f" Version: {data.get('VersionNumber', '?')}") - # Mirror to TTS Saves folder so it appears in Games -> Save & Load. - # Picks the next free TS_Save_N.json slot (TTS scans these on load). - saves_dir = Path.home() / "Library" / "Tabletop Simulator" / "Saves" - if saves_dir.exists(): - existing = sorted( - int(p.stem.split("_")[-1]) - for p in saves_dir.glob("TS_Save_*.json") - if p.stem.split("_")[-1].isdigit() - ) - # Reuse the highest existing slot if its content matches the prefix - # "SWL BETA - MAC PATCH"; else pick next free number. - target = None - for n in reversed(existing): - p = saves_dir / f"TS_Save_{n}.json" - try: - with p.open("r") as f: - if '"SWL BETA - MAC PATCH"' in f.read(512): - target = p - break - except OSError: - continue - if target is None: - next_n = (existing[-1] if existing else 0) + 1 - target = saves_dir / f"TS_Save_{next_n}.json" - with target.open("w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - print(f" Mirrored to: {target}") if reload_tts: print() diff --git a/mac-patcher/proposals-upstream.txt b/mac-patcher/proposals-upstream.txt new file mode 100644 index 000000000..ca80d2a18 --- /dev/null +++ b/mac-patcher/proposals-upstream.txt @@ -0,0 +1,98 @@ +Proposals for the maintainers, before we spend the time +======================================================= + +Iron Squadron, 12 August 2026. + +Everything below is work we are offering to do, not work we have done. We would +rather hear "no thanks" now than hand you a pull request you never asked for. +Each item says what is missing today, what it would cost us, and what it changes +for players. + +Context, in one line: we rebuilt the mod's custom-shader bundles so they carry a +Metal SubShader alongside the DirectX one, which fixes the magenta Projectors on +macOS. Working through that, we ran into two gaps that have nothing to do with +macOS. They affect Windows players exactly the same way. + + +1. COHESION IS MISSING ON 35 UNITS + +getCohesionLinks() ships three bundles: 27 mm, 50 mm and 70 mm. +spawnCohesionRuler ends with + + if cohesionBundleToSpawn == nil then return end + +so on any other base the cohesion ruler simply does not appear. Counting the +size field across contrib/cards: + + huge (100 mm) 16 units T-47 Airspeeder, AT-ST + epic (150 mm) 12 units AT-AP, LAAT/i Gunship + long (100 mm oblong) 3 units A-A5 Speeder Truck, Occupier Tank + laat (120 mm) 2 units LAAT/LE Patrol Transport + snail (100x200 oblong) 2 units Persuader-Class Tank Droid + +What we would build. Cohesion is the base outline offset outwards by range 0.5, +whatever the base's shape. That is not our interpretation, it is what the +existing bundles measure: their radii are 3.531, 3.984 and 4.378 inches for 27, +50 and 70 mm bases, i.e. the base radius plus 3.000 inches in all three cases. + +The three missing round sizes are nearly free: BB_CohesionProjector.shader is +procedural, driven by _BaseSize, so each one is a material and a prefab, with no +new art. + +The two oblong sizes need a shader that offsets a stadium rather than a circle, +and that maths already exists in this repo: BB_OblongRangeProjector.shader takes +a _BaseCenterPointGap. A cohesion variant is a merge of two shaders you already +own. + +Cost to you: none. We build, we test on both platforms, you review. + + +2. THE RANGE TEMPLATE COULD CARRY THE COHESION RING + +Same measurement, shown where players already look. A thin white ring at range +0.5 from the base edge, added to BB_RangeProjector and its oblong variant, on the +eight unit-leader sizes. Not on tokens: an objective or a POI is not a unit, so +cohesion means nothing there. + +This one is a genuine change to something that already works, so it is the item +we most want your opinion on before touching it. + + +3. THE QUESTION UNDERNEATH BOTH: OPT-IN OR FOR EVERYONE? + +We also maintain a small Lua layer that adds behaviours the vanilla mod does not +have: cohesion that follows a model while it is being dragged, range and cohesion +toggles that are deterministic instead of respawning, a maximum-move template +anchored where the move started. It sits behind a single table-wide button, off +by default, and it leaves the original mod strictly untouched when off. + +Rendering, however, no longer goes through that layer: it is in the bundles now. +So an enriched range template would be seen by everyone, in both button states. +Keeping it optional means publishing a second set of range bundles for the button +to select, which roughly doubles what we produce. + +Three ways to go, and it is your call: + +1. For everyone. Simplest, and hard to argue against for item 1, since the + vanilla mod shows nothing at all on those 35 units today. +2. Behind the button. Nothing imposed, players compare and decide. More work on + our side, and we are fine with that. +3. Item 1 for everyone, item 2 behind the button. Our own preference: the missing + cohesion is a gap to fill, the enriched range template is a taste question. + + +SEPARATELY, TWO UPSTREAM BUGS WE ALREADY CARRY A FIX FOR + +Neither is macOS-specific and neither needs a decision from you. Say the word and +we will send them as their own small pull request. + +- clearSilhouette() dereferences removeAttachments()[1] without checking it. + silhouetteState survives a save reload while the physical attachments do not, + so loading a game saved with silhouettes up and clicking SIL crashes the + object's script. +- The SIL and LCK buttons land on top of the model on long and snail bases: + calculateButtonZOffset uses a single per-size radius with no axis handling. + + +Contact: Iron Squadron, French Star Wars Legion association, +https://iron-squadron.fr diff --git a/mac-patcher/retro-cohesion.md b/mac-patcher/retro-cohesion.md deleted file mode 100644 index a67ea58c4..000000000 --- a/mac-patcher/retro-cohesion.md +++ /dev/null @@ -1,170 +0,0 @@ -# Rétro-ingé Cohesion Ruler - -Rédigé en mode prod, base pour le refactor event-driven vector lines (15 mai 2026, post-finding session 14-15 mai). - -## 1. Définition fonctionnelle (rappel règles SWL) - -Le **Cohesion Ruler** matérialise la **distance de cohésion d'unité** : tous les minis d'une même unité doivent rester à une distance maximale du leader d'unité (sinon malus / dispersion). Le ruler aide à vérifier visuellement cette contrainte pendant le tour. - -Distance de cohésion : **0.5 inch** (1,27 cm) entre les bords des socles, règle officielle SWL. - -## 2. Visuel attendu - -3 tailles, indexées par `baseSize` du leader : - -| Clé | Base size physique | Bundle | -|---|---|---| -| `small` | 27 mm | `https://steamusercontent-a.akamaihd.net/ugc/2482129948496305632/...` | -| `medium` | 50 mm | `https://steamusercontent-a.akamaihd.net/ugc/2482129948496305877/...` | -| `large` | 70 mm | `https://steamusercontent-a.akamaihd.net/ugc/2482129948496305957/...` | - -Rendu côté Windows (TTS Unity 2019.4) : cercle semi-transparent vert/cyan projeté au sol via Unity Projector, centré sur la fig. **À extraire empiriquement** via UnityPy + référence Workshop pour la couleur/alpha exacts (en TODO ci-dessous). - -Comportement Projector : drape sur le relief de la table (rochers, élévations) grâce au Projector legacy. - -## 3. Déclencheurs (qui peut spawn/clear le ruler ?) - -### Spawn - -1. **Hotkey global "Show Cohesion On Hovered Model"** — `StarWarsLegion.lua:611-619` - - Bind via `addHotkey()` côté Global - - Appelle `showCohesionOnHoveredModel(hoverObject)` qui : - - Si déjà selected → clear + reset `selectedUnitObj` - - Sinon → `clearCohesionRuler() + spawnCohesionRuler(hoverObject)` - - **État global** `selectedUnitObj` tracké côté Global (variable du script `StarWarsLegion.lua` via `require('!/Cohesion')`) - -2. **Bouton "COHESION" sur Order Token** — `Order_Token.a57c41.lua:347-352, 369-378` - - Bouton créé pendant l'activation d'une unité - - `toggleCohesionRuler()` toggle via flag `rulerOn` local au Order Token - - Appelle `selectedUnitObj.call("spawnCohesionRuler", selectedUnitObj)` → s'exécute dans le script de la **fig sélectionnée** (Unit_Leader.99f1c8) - -3. **Re-spawn automatique après mouvement** — `Unit_Leader.99f1c8.lua:278-286` - - `dropCoroutine()` attend que `getVelocity().y == 0` (fig au sol) - - Si `moveState == true` (= en cours de mesure de mouvement) → `spawnCohesionRuler(self)` - -### Clear - -1. **Hotkey re-press** — toggle via `showCohesionOnHoveredModel` (idem spawn, second appel) - -2. **Bouton COHESION re-press** — toggle via `toggleCohesionRuler` (idem) - -3. **Bouton invisible "unitID" sur Unit_Leader** — `Unit_Leader.99f1c8.lua:45` - - Bouton au centre de la fig avec label = numéro d'unité, couleur alpha 0.01 (quasi-invisible) - - `click_function = "clearCohesionRuler"` - - C'est l'overlay cliquable du numéro affiché sur chaque fig - -4. **onPickedUp sur Unit_Leader** — `Unit_Leader.99f1c8.lua:274-276` - - **Event-driven déjà existant** : pickup la fig → clear automatique - -5. **`standbyTokens()` global** — `StarWarsLegion.lua:562-571` - - Boucle sur `getAllObjects()` et destroy tout objet nommé `"Cohesion Ruler"` (et "Range Ruler", "Movement Template", "Deployment Boundary") - - Déclenché par... à tracer (probablement reset de partie ou hotkey debug) - -6. **`removeLockedRulers()` GAME_CONTROLLER** — `GAME_CONTROLLER.623b03.lua:148-155` - - Boucle sur `getAllObjects()` et destroy "Cohesion Ruler" / "Range Ruler" - - Appelé par la fonction de reload (`reloadObj` voisine) - -7. **`clearCohesionRulers()` (pluriel) Order Token** — `Order_Token.a57c41.lua:788-793` - - Appelé par `clearTemplates()` lors du reset après mouvement - - `selectedUnitObj.setVar("moveState", false)` + `selectedUnitObj.call("clearCohesionRuler")` - -## 4. Lifecycle complet - -``` -[Spawn] - showCohesionOnHoveredModel(fig) [hotkey hover] - toggleCohesionRuler() [via Order Token bouton] - dropCoroutine() [auto si moveState] - ↓ - spawnCohesionRuler(fig): - 1. unitData = fig.getTable("unitData") - 2. bundleURL = getCohesionLinks()[unitData.baseSize] - 3. spawnObject(Custom_AssetBundle, pos = fig.pos + Y+20, scale = 0, rot = (0, fig.rot.y, 0)) - 4. setCustomObject({type=0, assetbundle=bundleURL}) - 5. setLock(true), use_gravity=false, setName("Cohesion Ruler") - ↓ -[Ruler affiché — STATIQUE, ne suit pas la fig] - Note: pas de onFixedUpdate. Si fig bouge sans pickup (impossible normalement - car les figs sont lockables), le ruler reste à l'ancienne position. - -[Clear] - showCohesionOnHoveredModel(same fig) [hotkey re-press toggle] - toggleCohesionRuler() [re-press toggle] - onPickedUp(fig) [pickup auto] - clearCohesionRuler() [click invisible "unitID" button] - standbyTokens() [global reset] - removeLockedRulers() [reload] - clearCohesionRulers() [via Order Token clearTemplates] - ↓ - clearCohesionRuler(): - 1. destroyObject(cohesionRuler) - 2. cohesionRuler = nil - -[Re-spawn auto post-drop] - onDropped → checkVelocity → si moveState → startLuaCoroutine(dropCoroutine) - ↓ - dropCoroutine: - while velocity.y ~= 0: yield - if moveState == true: spawnCohesionRuler(self) -``` - -## 5. État scopé : ATTENTION pattern hétérogène - -`cohesionRuler` est une variable Lua **scopée au script Lua qui fait le require**. Chaque objet qui require `!/Cohesion` a SA propre `cohesionRuler`. Trois scopes coexistent : - -| Site require | Scope `cohesionRuler` | -|---|---| -| `StarWarsLegion.lua:8` (Global) | Script Global | -| `Unit_Leader.99f1c8.lua:1` (chaque fig leader) | Par-fig | -| `Order_Token.a57c41.lua:4` (chaque Order Token) | Par-Order-Token | - -**Conséquence pratique** : -- Hotkey hover → spawn dans le scope Global → `cohesionRuler` du Global mis à jour, **pas celui de la fig** -- Bouton COHESION → `selectedUnitObj.call("spawnCohesionRuler", selectedUnitObj)` → exécute dans le scope de la fig → `cohesionRuler` de la fig mis à jour -- `onPickedUp(fig)` → clear dans le scope de la fig → ne clear PAS un ruler spawn via hotkey hover (mais le bouton invisible "unitID" est sur la fig, donc dans le scope fig...) - -**Bug latent connu (non reproduit)** : si tu spawn via hotkey hover, puis tu pickup une autre fig, le ruler du Global subsiste. À garder en tête pour le refactor (à corriger ou pas selon scope). - -## 6. Filtres de cleanup global - -Deux fonctions scannent `getAllObjects()` et destroy par nom : - -| Fonction | Fichier | Cible | -|---|---|---| -| `standbyTokens()` | StarWarsLegion.lua:562 | "Cohesion Ruler" + "Range Ruler" + "Movement Template" + "Deployment Boundary" | -| `removeLockedRulers()` | GAME_CONTROLLER.623b03.lua:148 | "Cohesion Ruler" + "Range Ruler" | - -**Impact refactor** : si on remplace l'Object Custom_AssetBundle par des vector lines (qui ne sont pas des Objects), ces deux filtres deviennent **caducs**. Il faudra leur substituer un clear de la table d'état globale + `Global.setVectorLines({})`. - -## 7. Paramètres visuels exacts — TODO - -À extraire empiriquement via UnityPy sur les bundles small/medium/large : - -- [ ] Bundle `halfcohesion_27mm.unity3d` (ou équivalent small) : material color, alpha, orthographicSize du Projector, animation éventuelle -- [ ] Bundle medium (50mm) : idem -- [ ] Bundle large (70mm) : idem -- [ ] Screenshot référence côté Workshop (rendu attendu côté Windows TTS 2019.4 / U6 non-magenta) - -Scripts UnityPy disponibles dans `Mod TTS SWL/` (`inspect_cohesion.py`, `inspect_externals.py`). À adapter pour extraire les params materials. - -## 8. Implications pour le refactor - -### Bonnes nouvelles -- API publique petite et conservable : `showCohesionOnHoveredModel`, `spawnCohesionRuler`, `clearCohesionRuler` -- 80% du pattern event-driven déjà en place (`onPickedUp`, `dropCoroutine`) -- Lifecycle bien défini, déclencheurs énumérés exhaustivement - -### Décisions à prendre dans le design doc -1. **Unifier les scopes** : faire que `cohesionRuler` soit une table globale indexée par GUID (Variante A) plutôt que par-script. Résout le bug latent (§5) et permet le pattern multi-figs simultanées. -2. **Remplacer les filtres de cleanup** (§6) par des appels à la fonction de clear globale. -3. **Reproduire le drape sur relief** via `Physics.cast()` par segment, comme le POC validé du 14-15 mai. -4. **Compatibilité Order Token bouton** : conserver la sémantique toggle `rulerOn` ou simplifier. - -(Note : esthétique du rendu vector lines hors specs — on traitera une fois le dossier refactor clos.) - -## 9. Références - -- Code : `swlegion-tts/mod/src/includes/Cohesion.ttslua`, `data/CohesionLinks.ttslua`, `StarWarsLegion.lua`, `StarWarsLegion/Unit_Leader.99f1c8.lua`, `StarWarsLegion/Order_Token.a57c41.lua`, `StarWarsLegion/GAME_CONTROLLER.623b03.lua` -- Bundles : cache TTS local, sources Unity dans `Mod TTS SWL/UnityProject-U6/Assets/` -- Session 14-15 mai validation empirique : `~/.claude/projects/-Users-martinpourrat-MARTIN-Star-Wars-Legion/memory/mod-tts-swl/session-14-mai.md` -- Plan B identifié : `~/.claude/projects/-Users-martinpourrat-MARTIN-Star-Wars-Legion/memory/mod-tts-swl/paths-forward.md` diff --git a/mac-patcher/retro-deployment.md b/mac-patcher/retro-deployment.md deleted file mode 100644 index 20d1741ef..000000000 --- a/mac-patcher/retro-deployment.md +++ /dev/null @@ -1,76 +0,0 @@ -# Rétro-ingé Deployment Boundary - -## Visuel attendu - -Zones de déploiement rouge/bleu peintes au sol au début de la partie selon le scenario. Pattern matriciel avec 14 codes de cellule : - -| Code | Sens | -|---|---| -| `r` / `b` | Base red / blue | -| `rh` / `bh` | Home (zone arrière) | -| `rs` / `bs` | Side (zone latérale) | -| `rss` / `bss` | Side stretched | -| `rl` / `bl` | Long (zone allongée, spawn 2 cellules : sX + ccX) | -| `rc` / `bc` | Corner | -| `rcc` / `bcc` | Corner-corner (autre orientation) | - -URLs bundles dans `deployLinks` (`SETUP_CONTROLLER.1cb552.lua:188-202`). - -Plusieurs codes partagent la même URL (e.g. `rh = rs = rss` → même bundle, rotation différente). Distinct rotations : `r=0, rh=0, rs=90, rss=90, ...` (`deployRotations` ligne 204). - -Rendu côté Windows : grand rectangle/zone projeté au sol via Projector. Couleurs rouge/bleu. **À extraire via UnityPy** (task #13). - -## Spawn - -**1 seul déclencheur** : `spawnDeploymentBoundary(matrix)` — `SETUP_CONTROLLER.1cb552.lua:267-314` - -- Reçoit une matrice 12×N (codes par cellule) inversée puis lue ligne par ligne -- Pour chaque cellule non-vide : `spawnBoundaryCell(cell, x, z)` qui spawn 1 Custom_AssetBundle au pos calculé `{xStart + 6*(x-1), yValue, zStart - 6*(z-1)}` + `deployOffset[cell]` -- Cas spécial `bl`/`rl` : spawn 2 cellules (sX + ccX) pour matérialiser le L - -Appelé par `SETUP_CONTROLLER.1cb552.lua:369` (probablement après sélection de la carte Deployment via UI menu). - -## Clear - -**1 seul déclencheur** : `clearDeploymentBoundary()` — ligne 316-323 -- Boucle sur `battlefieldZone.getObjects()` et `destroyObject` chaque objet nommé `"Deployment Boundary"` - -Aussi via : -- **`standbyTokens()`** — `StarWarsLegion.lua:567` — destroy global par nom - -(Pas dans `removeLockedRulers` de GAME_CONTROLLER : Deployment n'est pas dans la liste.) - -## Lifecycle - -``` -[Setup partie] - Menu UI "Mount Deployment" → checkDeployment() → spawnDeploymentBoundary(matrix) - ↓ - Loop sur matrix → pour chaque cell : spawnBoundaryCell - ↓ - Custom_AssetBundle spawn statique (scale 0, locked, no gravity) - -[Clear] - Menu UI "Remove Overlay" → clearDeploymentBoundary() - ↓ - Boucle battlefieldZone → destroyObject par nom - -[Aussi : standbyTokens() global au reset] -``` - -**Pattern : 100% statique.** Pas de suivi, pas d'event. Spawn au setup, clear quand on remove. Plus simple que Cohesion et Range. - -## Différences clés vs Cohesion/Range - -1. **Pas de fig source** — c'est un overlay de zone, pas un overlay attaché à un objet mobile. Pas besoin de drape sur relief si la table est plate (à confirmer : sur certaines tables custom avec relief, est-ce que la zone de déploiement doit draper ? probablement oui pour cohérence visuelle). -2. **Multi-cellules** par scenario — peut-être 10-20 Custom_AssetBundle spawn simultanément (vs 1 pour Cohesion/Range). -3. **Pas de variable d'état scopée** — pas de `deploymentRuler = nil` ; le code itère sur `battlefieldZone.getObjects()` filtré par nom pour le cleanup. - -## Implications refactor - -- API publique à conserver : `spawnDeploymentBoundary(matrix)`, `clearDeploymentBoundary()` -- 14 bundle URLs (mais visuellement ~6 zones distinctes vu les doublons URL) → 6 sets de params visuels à extraire (task #13) -- Pas d'event-driven nécessaire — statique simple -- En vector lines : chaque cellule = un polygone fermé `setVectorLines({{points={p1,p2,p3,p4,p1}, ...}, ...})`. Pas de Physics.cast si table plate, sinon raycast par segment du contour -- Filtre cleanup `standbyTokens` à adapter pour clear la table d'état globale (ou laisser un `Global.setVectorLines({})` de la collection deployment) -- **Décision design** : Deployment partage-t-il la même collection `Global.setVectorLines()` que Cohesion/Range, ou faut-il un canal séparé ? `Global.setVectorLines` est singleton donc tout doit cohabiter dans la même collection. Implication : la table d'état globale doit indexer par type (`cohesion` / `range` / `deployment`) pour gérer les clears partiels. diff --git a/mac-patcher/retro-movement.md b/mac-patcher/retro-movement.md deleted file mode 100644 index 579aaca3b..000000000 --- a/mac-patcher/retro-movement.md +++ /dev/null @@ -1,122 +0,0 @@ -# Rétro-ingé Movement Template + Maximum Move - -**Correction** : Movement implique **2 objets visuels** spawn ensemble lors de l'activation de mouvement. Un seul des deux est cassé magenta. - -## Composant 1 : Movement Template A/B (templates 3D courbes) — NON AFFECTÉ - -Code : `Order_Token.a57c41.lua:480-529` - -```lua -templateA = spawnObject({ type = "Custom_AssetBundle", scale = {1,1,1} ... }) -templateA.setCustomObject({ assetbundle = ..., material = 1 }) -templateA.setColorTint(...) -templateA.setName("Movement Template (A)") -``` - -- **scale = {1,1,1}** → mesh visible (pas un Projector caché) -- **`material = 1`** + **`setColorTint()`** → material standard avec colorTint TTS exposé -- Bundles `longBundle` / `shortBundle` / `sharedBundle` par speed (templateInfo) -- **Pas affecté par bug magenta** : c'est un mesh classique, pas un Projector receiver - -## Composant 2 : Maximum Move (cercle de portée) — CASSÉ MAGENTA ✅ - -Code : `Order_Token.a57c41.lua:552-574` - -```lua -maxMoveTemplate = spawnObject({ - type = "Custom_AssetBundle", - position = {basePos.x, basePos.y + 20, basePos.z}, - scale = {0,0,0} -- ← scale 0, pattern Projector -}) -maxMoveTemplate.setCustomObject({ - type = 0, - assetbundle = maxMoveTemplateBundleToSpawn -}) -maxMoveTemplate.setLock(true) -maxMoveTemplate.use_gravity = false -maxMoveTemplate.setName("Maximum Move") -- ← nom différent de "Movement Template" -``` - -- **scale = {0,0,0}** → Projector legacy (même pattern que Cohesion/Range/Deployment) -- **Pas de setColorTint** sur ce spawn -- **CASSÉ MAGENTA** sur Mac confirmé par Martin - -### Bundles (`includes/data/MovementLinks.ttslua`) - -`getMovementLinks()` retourne table indexée `[baseSize][selectedSpeed]` : - -| baseSize | Taille | Speeds | -|---|---|---| -| `small` | 27mm | 3 bundles (speed 1/2/3) | -| `medium` | 50mm | 3 | -| `large` | 70mm | 3 | -| `huge` | 100mm | 3 | -| `laat` | 120mm | 3 | -| `epic` | 150mm | 3 | -| `long` | 100×175mm oblong | 3 | -| `snail` | 100×200mm oblong | 3 | - -**Total : 24 bundles uniques** pour le cercle de portée max. - -Rendu visuel attendu côté Windows : cercle/anneau projeté au sol matérialisant la portée maximale de mouvement à cette vitesse. **Couleur/alpha à extraire via UnityPy** (task #13). - -## Déclencheurs - -### Spawn -**1 seul déclencheur** : flow d'activation mouvement via Order Token -- Bouton speed (1/2/3) cliqué sur Order Token → set `unitData.selectedSpeed` -- Une fonction de mouvement (autour de `Order_Token.a57c41.lua:480+`) spawn `templateA`, `templateB`, ET `maxMoveTemplate` ensemble -- Conditional : `if isDeploy == false` → pas de Maximum Move pendant la phase de déploiement (ligne 556) - -### Clear -**Fonction `clearMovementTemplates()`** — `Order_Token.a57c41.lua:776-786` -```lua -function clearMovementTemplates() - if templateA ~= nil then destroyObject(templateA) end - if templateB ~= nil then destroyObject(templateB) end - if maxMoveTemplate ~= nil then destroyObject(maxMoveTemplate) end -end -``` - -Appelé par : -- `clearTemplates()` (ligne 770-774) → appelé après le drop final du mouvement -- Aucun cleanup global (Maximum Move PAS dans `standbyTokens()` ni `removeLockedRulers()`) - -## Lifecycle - -``` -[Spawn] - Order Token bouton speed (1/2/3) → spawn flow - ↓ - templateA, templateB (mesh visible) + maxMoveTemplate (Projector) - ↓ - L'utilisateur déplace templateA jusqu'à la position cible - → contrôle visuel "ma fig peut-elle aller là" avec le cercle Maximum Move - -[Clear] - Drop final (fig placée) → clearTemplates() → destruct 3 objets ensemble -``` - -**Pattern : statique au spawn, pas de suivi de fig**. Le cercle Maximum Move est positionné une fois à `basePos + Y20` et reste là pendant que tu manipules les templates de mouvement. - -## État scopé - -`maxMoveTemplate` est une variable par Order Token (`require('!/Cohesion')` ligne 4 mais maxMoveTemplate vient du flow Order_Token directement, pas d'include). Pas de partage Global / Unit_Leader. - -## Implications refactor - -- API publique à conserver : le spawn et `clearMovementTemplates()` (mais cette dernière mélange templates A/B + maxMoveTemplate) -- 24 bundles cercle de portée → 8 sets de params visuels distincts par baseSize (les 3 speeds partagent probablement le même material avec rayon différent ; à extraire task #13) -- Pattern statique simple : spawn une fois, clear ensemble avec templates A/B -- En vector lines : 1 cercle par maxMoveTemplate, dessiné via setVectorLines + Physics.cast par segment pour drape relief -- Cleanup propre : `clearMovementTemplates` doit clear la table d'état globale spécifique au maxMove de cet Order Token -- **Templates A/B (mesh) restent inchangés** — pas dans le scope du refactor - -## Couleur attendue - -Le cercle Maximum Move devrait visuellement matcher la couleur du template de speed correspondant : -- Speed 1 : couleur 1 (vert ?) -- Speed 2 : couleur 2 (jaune ?) -- Speed 3 : couleur 3 (rouge ?) - -Note `templateInfo.moveTemplate[selectedSpeed].colorTint` — c'est le tint des templates A/B, mais le cercle Maximum Move utilise un bundle distinct sans colorTint (le bundle embarque sa propre couleur). À vérifier empiriquement / via UnityPy quelle est la couleur baked-in de chaque bundle Maximum Move par speed. diff --git a/mac-patcher/retro-range.md b/mac-patcher/retro-range.md deleted file mode 100644 index a4569069d..000000000 --- a/mac-patcher/retro-range.md +++ /dev/null @@ -1,86 +0,0 @@ -# Rétro-ingé Range Ruler - -## Visuel attendu - -12 tailles dans `getRangeRulerLinks()` (`mod/src/includes/data/RangeRulerLinks.ttslua`) : - -| Clé | Cible | Range | -|---|---|---| -| `small` | base 27mm | range mini | -| `medium` | base 50mm | range mini | -| `large` | base 70mm | range mini | -| `huge` | base 100mm | range véhicule | -| `laat` | base 120mm | range LAAT | -| `epic` | base 150mm | range épique | -| `long` | base 100mm oblong | range véhicule long | -| `snail` | base 100x200mm | range escargot | -| `bombCart` | bomb cart | range spécifique | -| `smokeToken` | jeton fumée 18.8mm | range 1 | -| `token` | jeton charge/objectif/condition 25.1mm | range 1 | -| `tokenRangeTwo` | jeton graffiti 25.1mm | range 2 | -| `poi` | POI token 50.8mm | range 0.5 (3") | - -Rendu côté Windows : **4 cercles concentriques** projetés au sol via Unity Projector legacy (1 bundle = N Projectors embarqués pour les bandes de portée 1/2/3/4). Couleurs/alphas/rayons par bande **à extraire via UnityPy** (task #13). - -**Configurations par overlay** : -- Rulers de fig (small/med/large/huge/laat/epic/long/snail) : **4 cercles** (range 1/2/3/4) -- `smokeToken` : 1 cercle (range 1, effet smoke 1 pouce) -- `token` (charge/obj/cond) : 1 cercle (range 1) -- `tokenRangeTwo` (graffiti) : 2 cercles (range 1 + 2) -- `poi` : 1 cercle (range 0.5 = 3") -- `bombCart` : à confirmer empiriquement - -## Déclencheurs - -### Spawn -1. **Hotkey "Show Range On Hovered Model"** — `StarWarsLegion.lua:601-608` → `showRangeOnHoveredModel(hoverObject)` -2. **Bouton "RANGE" sur Order Token** — `Order_Token.a57c41.lua:354-365` → `targetingMode()` ligne 922 → `spawnRangeRuler(selectedUnitObj)` -3. **Bouton "RANGE" via attackMode** — `Order_Token.a57c41.lua:934` → `spawnRangeRuler(selectedUnitObj)` -4. **Bouton "R" sur POI Token** — `POI_Tokens/POI_Token.761483.lua:23,41-46` → `toggleRangeRuler()` → `spawnTokenRangeRuler()` → `spawnRangeRuler(self, tokenRulerBundle)` avec override -5. **Bouton "R" sur autres tokens** — `includes/TokenWithRangeRuler.ttslua:37-43,52-56` (smokeToken, token, tokenRangeTwo via `rangeKey` scopé par-token) - -### Clear -1. **Hotkey re-press** — toggle via `showRangeOnHoveredModel` -2. **`clearRangeRulers()` (pluriel)** — Order Token : appelé par `clearTemplates`, `exitTargetingMode`, `exitAttackMode`, `attackMenu` -3. **`clearRangeRuler()` (singulier)** — POI/Token : appelé par `toggleRangeRuler` et `onDestroy` -4. **`standbyTokens()` global** — `StarWarsLegion.lua:567` — destroy tout objet nommé `"Range Ruler"` -5. **`removeLockedRulers()`** — `GAME_CONTROLLER.623b03.lua:151` — destroy par nom - -## Différence clé vs Cohesion : suivi temps réel - -`RangeRulers.ttslua:78-79` : - -```lua -luaScript = "targetGUID = '"..rangeSourceObject.getGUID().."'\n" - .. "function onFixedUpdate()\n" - .. " if targetGUID ~= nil then\n" - .. " targetObj = getObjectFromGUID(targetGUID)\n" - .. " local targetPosition = targetObj.getPosition()\n" - .. " self.setPosition({targetPosition.x, targetPosition.y + 20, targetPosition.z})\n" - .. " self.setRotation({0,targetObj.getRotation().y,0})\n" - .. " end\n" - .. "end" -rangeRuler.setLuaScript(luaScript) -``` - -→ Le ruler **se repositionne 60 fois/sec** côté objet ruler (pas côté Lua de la fig). Différent de Cohesion qui est statique. - -**Implication refactor** : pour Range, le suivi temps réel est utile gameplay (tu mesures pendant que tu déplaces la fig pour évaluer si tu vas être en range). On ne peut pas simplement passer en pur event-driven sans perte UX. Le drape sur relief recalculé doit suivre — soit redraw à chaque tick visible (avec mémoization de position pour éviter le leak), soit accepter une dégradation UX (redraw au drop seulement). - -## État scopé (même problème que Cohesion §5) - -Variable `rangeRuler` scopée par script-objet (Global, chaque fig leader, chaque POI Token, chaque token générique). Pattern identique à Cohesion : 3+ scopes peuvent désaccorder. - -`selectedUnitObj` global aussi tracké côté Range (`showRangeOnHoveredModel` ligne 7-17 de RangeRulers.ttslua). - -## Override bundle (paramètre `projectorBundleOverride`) - -`spawnRangeRuler(rangeSourceObject, projectorBundleOverride)` — le 2e paramètre permet de fournir une URL bundle au lieu de la lookuper via `unitData.baseSize`. Utilisé par POI Token et TokenWithRangeRuler qui passent `rangeRulerTable[rangeKey]` directement. À conserver dans le refactor. - -## Implications refactor - -- API publique à conserver : `showRangeOnHoveredModel`, `spawnRangeRuler` (avec override), `clearRangeRulers` (Order Token), `clearRangeRuler` (POI/Token) -- 12 bundle URLs → 12 sets de params visuels à extraire (chacun avec **1 à 4 cercles concentriques** selon le type) et reproduire en vector lines (task #13) -- Pattern suivi temps réel → décision design (redraw par tick avec mémoization vs redraw event-driven uniquement) -- Filtres cleanup global (`standbyTokens`, `removeLockedRulers`) à adapter pour la table d'état -- Override `projectorBundleOverride` → s'inscrit naturellement dans une table d'état `{fig=obj, rangeKey="poi"}` ou similaire diff --git a/mac-patcher/scan_bundles.py b/mac-patcher/scan_bundles.py deleted file mode 100644 index eb5edfd22..000000000 --- a/mac-patcher/scan_bundles.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -"""Scan TTS AssetBundles for material -> shader references.""" -import UnityPy -import os -import sys -from collections import defaultdict, Counter - -BUNDLE_DIR = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles/") -STOCK_PREFIXES = ( - "Standard", - "Hidden/", - "Mobile/", - "Particles/", - "UI/", - "Sprites/", - "Skybox/", - "Legacy Shaders/", - "Universal Render Pipeline/", - "URP/", - "Unlit/", - "GUI/", - "Lightmap", - "VertexLit", - "Reflective/", - "Self-Illumin/", - "Transparent/", - "FX/", - "Nature/", - "TextMeshPro/", - "TextMesh Pro/", -) - -def is_stock(name: str) -> bool: - return any(name.startswith(p) for p in STOCK_PREFIXES) - -shader_to_bundles = defaultdict(set) -shader_material_count = Counter() -bundle_to_shaders = defaultdict(set) -embedded_shaders_per_bundle = defaultdict(set) -errors = [] -bundle_summary = [] - -bundles = sorted(f for f in os.listdir(BUNDLE_DIR) if f.endswith(".unity3d")) -print(f"Scanning {len(bundles)} bundles in {BUNDLE_DIR}\n", file=sys.stderr) - -for fname in bundles: - path = os.path.join(BUNDLE_DIR, fname) - short_id = fname.replace("httpssteamusercontentaakamaihdnetugc", "")[:20] - fsize = os.path.getsize(path) - n_mat = 0 - n_shader = 0 - n_tex = 0 - try: - env = UnityPy.load(path) - for obj in env.objects: - t = obj.type.name - if t == "Material": - n_mat += 1 - try: - data = obj.read() - shader_name = None - try: - sref = data.m_Shader - # Try multiple ways to resolve PPtr - try: - shader_obj = sref.deref() - except Exception: - shader_obj = None - if shader_obj is None: - try: - shader_obj_read = sref.read() - if shader_obj_read is not None: - shader_name = ( - getattr(getattr(shader_obj_read, "m_ParsedForm", None), "m_Name", None) - or getattr(shader_obj_read, "m_Name", None) - ) - except Exception: - pass - else: - sd = shader_obj.read() - shader_name = ( - getattr(getattr(sd, "m_ParsedForm", None), "m_Name", None) - or getattr(sd, "m_Name", None) - ) - except Exception as e: - errors.append(f"{short_id} mat shader resolve: {e!r}") - if not shader_name: - # PPtr fields - try: - file_id = getattr(data.m_Shader, "file_id", "?") - path_id = getattr(data.m_Shader, "path_id", "?") - shader_name = f"" - except Exception: - shader_name = "" - # Material name - mat_name = getattr(data, "m_Name", "") or "" - shader_to_bundles[shader_name].add(short_id) - shader_material_count[shader_name] += 1 - bundle_to_shaders[short_id].add((shader_name, mat_name)) - except Exception as e: - errors.append(f"{short_id} mat read err: {e!r}") - elif t == "Shader": - n_shader += 1 - try: - data = obj.read() - sname = ( - getattr(getattr(data, "m_ParsedForm", None), "m_Name", None) - or getattr(data, "m_Name", None) - ) - if sname: - embedded_shaders_per_bundle[short_id].add(sname) - except Exception as e: - errors.append(f"{short_id} shader read: {e!r}") - elif t in ("Texture2D", "Texture"): - n_tex += 1 - except Exception as e: - errors.append(f"{short_id}: load failed: {e!r}") - - bundle_summary.append((short_id, fsize, n_mat, n_shader, n_tex)) - -# === REPORT === -print("=" * 90) -print("BUNDLE SUMMARY (id_prefix, size_bytes, n_materials, n_shaders_embedded, n_textures)") -print("=" * 90) -for short_id, fsize, n_mat, n_shader, n_tex in bundle_summary: - print(f" {short_id} {fsize:>10} mats={n_mat:>3} shaders={n_shader:>2} tex={n_tex:>3}") - -print() -print("=" * 90) -print("SHADER FREQUENCY ACROSS ALL MATERIALS (sorted by usage)") -print("=" * 90) -for sname, count in shader_material_count.most_common(): - flag = " [CUSTOM?]" if not is_stock(sname) and not sname.startswith("4} mats / {n_bundles:>2} bundles {sname}{flag}") - -print() -print("=" * 90) -print("EMBEDDED SHADERS (shaders bundled INSIDE a .unity3d, sorted)") -print("=" * 90) -all_embedded = set() -for shaders in embedded_shaders_per_bundle.values(): - all_embedded.update(shaders) -print(f"\nDistinct embedded shaders: {len(all_embedded)}") -for s in sorted(all_embedded): - bundles_with = [b for b, ss in embedded_shaders_per_bundle.items() if s in ss] - print(f" - {s} ({len(bundles_with)} bundle(s): {','.join(bundles_with[:3])}{'...' if len(bundles_with) > 3 else ''})") - -print() -print("=" * 90) -print("BUNDLES REFERENCING NON-STOCK SHADERS (with material names)") -print("=" * 90) -for short_id, items in sorted(bundle_to_shaders.items()): - custom = [(s, m) for (s, m) in items if not is_stock(s) and not s.startswith(" float (précision Metal) + patch_shader_vertexcolor.py --apply # v.color -> blanc (effets lumineux) + Unity 6 -executeMethod BuildAllTargets.Run -target mac -out AssetBundles-mac + merge_all_bundles.py AssetBundles-win AssetBundles-mac AssetBundles-dual + patch_shader_precision.py --restore # remet les sources d'origine + DUAL_DIR=AssetBundles-dual install_dual_to_cache.py --install + + On ne rebuilde que la cible macOS : le SubShader Windows du bundle fusionné + est celui du build Windows, que la fusion ne touche pas. + + Voie greffe, quand il n'y a pas de sources + + Unity 2019.1.9f1 -executeMethod BuildMetalGraft.Run -out AssetBundles-graft + graft_metal_2019_1.py + install_grafted_to_cache.py --install + + C'est la voie à dérive nulle : le bundle publié garde ses maillages, ses + textures et ses matériaux inchangés, on ne lui ajoute qu'un SubShader. C'est + la voie des 8 orphelins sans sources, et aussi celle de stunt_double_bot_1, + écarté du rebuild parce que celui-ci lui ajoutait un socle que l'objet publié + n'a pas. + + La version d'Unity doit correspondre à celle du bundle publié. Les 248 + bundles se répartissent sur cinq versions : 2019.4.19f1 (144), 2019.1.9f1 + (83), 2019.1.0f2 (11), 2019.4.40f1 (9), 5.3.4f1 (1). Elle se lit dans les 200 + premiers octets du fichier, en clair après la signature UnityFS. + + Entre 2019.1 et 2019.4, la sérialisation des shaders change : les tables du + blob passent de plates à imbriquées. D'où deux scripts de fusion qui refusent + chacun le format de l'autre : + + merge_subshader_platforms.py listes imbriquées, champ stageCounts, Unity 6 + graft_metal_2019_1.py listes plates, Unity 2019.1 + + +POURQUOI UN SUBSHADER ENTIER ET PAS UNE PASSE FUSIONNÉE + +Les tables de liaison de paramètres (m_NameIndices, m_CommonParameters, +m_ConstantBufferBindings) sont stockées une fois par passe et partagées par +toutes les plateformes. DirectX et OpenGL cohabitent parce qu'ils partagent +cette disposition. Metal, lui, éclate les globals en VGlobals/FGlobals et remet +les offsets à zéro : greffer un programme Metal dans une passe DirectX lui donne +les mauvaises liaisons, et il rend faux sans aucun message d'erreur. + +Un shader peut en revanche déclarer plusieurs SubShaders, chacun avec ses +propres passes donc ses propres tables. On empile donc le SubShader macOS +derrière celui d'origine : Unity descend au suivant quand le premier n'a pas de +variante pour l'API courante. + + +PIÈGES RENCONTRÉS + +- La sauvegarde ne se juge pas à son existence mais à son contenu. Un bundle + simplement déposé au premier passage existe au second et se fait sauvegarder + alors que c'est déjà le nôtre. 60 entrées sur 114 étaient dans ce cas. Le + garde-fou est is_ours() : aucun bundle publié ne porte de variante Metal, donc + en trouver une prouve que le fichier vient de nous. +- Toujours greffer depuis le bundle publié, jamais depuis un bundle déjà greffé, + sinon on empile les SubShaders à chaque passage. +- macOS tue les vieux éditeurs Unity au lancement (SIGKILL, aucun log écrit), + avec une alerte « Unity est endommagé ». Ce n'est pas une quarantaine : la + réponse est Réglages Système -> Confidentialité et sécurité -> « Ouvrir quand + même ». Ne jamais cliquer « Placer dans la corbeille ». +- Certains shaders sont en latin-1 (l'en-tête « © Allen White ») : lire en + binaire, ne pas réencoder. +- UnityPy : m_Colors donne des dictionnaires modifiables, m_Floats des tuples + immuables. Pour modifier un float, reconstruire la liste. +- zsh ne découpe pas les variables non quotées : pour une longue liste de + chemins, passer par bash -c avec un tableau. + + +CE QU'ON NE TOUCHE JAMAIS + +- Les bundles sans shader custom : ils n'ont jamais été cassés. 114 sur 266. +- Les Custom Models (mesh + diffuse au lieu de bundle) : TTS leur applique son + propre matériau, ils ne peuvent pas être magenta. Ils sont majoritaires dans + le mod. diff --git a/mac-patcher/tools/UnityProject-2019.1-Editor/BuildMetalGraft.cs b/mac-patcher/tools/UnityProject-2019.1-Editor/BuildMetalGraft.cs new file mode 100644 index 000000000..2b8131fe9 --- /dev/null +++ b/mac-patcher/tools/UnityProject-2019.1-Editor/BuildMetalGraft.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +// Greffon Metal au format de serialisation Unity 2019, 12 aout 2026. +// +// But : produire un bundle qui ne contient RIEN d'autre que les shaders du mod, +// compiles pour Metal. merge_subshader_platforms.py empile ensuite leur +// SubShader derriere celui du bundle PUBLIE, qui ne connait que d3d11/glcore. +// Le bundle publie garde donc ses maillages, ses textures et ses materiaux bit +// pour bit : c'est la voie zero-derive, et la seule ouverte pour les 8 +// orphelins dont on n'a pas les sources. +// +// Un shader n'entre dans un bundle que s'il est reference : on cree donc un +// materiau par shader, porte par un quad, et c'est tout. +// +// Usage : +// -executeMethod BuildMetalGraft.Run [-out ] +public static class BuildMetalGraft +{ + const string BundleName = "metal_graft"; + const string StageDir = "Assets/_Graft"; + + public static void Run() + { + string outputPath = Arg("-out", "AssetBundles-graft"); + + if (!Directory.Exists(StageDir)) + AssetDatabase.CreateFolder("Assets", "_Graft"); + + string[] shaderGuids = AssetDatabase.FindAssets("t:Shader", new[] { "Assets/Shaders" }); + if (shaderGuids.Length == 0) + { + Debug.LogError("GRAFT: aucun shader dans Assets/Shaders"); + EditorApplication.Exit(1); + return; + } + + foreach (string guid in shaderGuids) + { + string shaderPath = AssetDatabase.GUIDToAssetPath(guid); + Shader shader = AssetDatabase.LoadAssetAtPath(shaderPath); + if (shader == null) + { + Debug.LogError("GRAFT: illisible " + shaderPath); + EditorApplication.Exit(2); + return; + } + if (shader.name.StartsWith("Hidden/InternalErrorShader")) + { + Debug.LogError("GRAFT: " + shaderPath + " ne compile pas (InternalErrorShader)"); + EditorApplication.Exit(3); + return; + } + + string matPath = StageDir + "/" + Path.GetFileNameWithoutExtension(shaderPath) + ".mat"; + var mat = new Material(shader); + AssetDatabase.CreateAsset(mat, matPath); + + var importer = AssetImporter.GetAtPath(matPath); + importer.assetBundleName = BundleName; + + Debug.Log("GRAFT: " + shader.name + " <- " + shaderPath); + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Directory.CreateDirectory(outputPath); + var manifest = BuildPipeline.BuildAssetBundles( + outputPath, BuildAssetBundleOptions.None, BuildTarget.StandaloneOSX); + + if (manifest == null) + { + Debug.LogError("GRAFT: build echoue"); + EditorApplication.Exit(4); + return; + } + + string produced = Path.Combine(outputPath, BundleName); + if (!File.Exists(produced)) + { + Debug.LogError("GRAFT: " + produced + " absent apres build"); + EditorApplication.Exit(5); + return; + } + + Debug.Log(string.Format("GRAFT: termine, {0} shaders, {1} octets -> {2}", + shaderGuids.Length, new FileInfo(produced).Length, produced)); + } + + static string Arg(string name, string fallback) + { + var args = Environment.GetCommandLineArgs(); + int i = Array.IndexOf(args, name); + return (i >= 0 && i + 1 < args.Length) ? args[i + 1] : fallback; + } +} diff --git a/mac-patcher/tools/graft_metal_2019_1.py b/mac-patcher/tools/graft_metal_2019_1.py new file mode 100644 index 000000000..e2504f6a6 --- /dev/null +++ b/mac-patcher/tools/graft_metal_2019_1.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Greffe un SubShader Metal dans un bundle PUBLIE serialise par Unity 2019.1. + +Pourquoi un second script a cote de merge_subshader_platforms.py : celui-ci a +ete ecrit pour des bundles produits par Unity 6, ou les tables du blob sont des +listes DE LISTES (un tableau par plateforme, un element par sous-programme) et +ou il existe un champ stageCounts. En 2019.1 ces tables sont PLATES, un entier +par plateforme, et stageCounts n'existe pas — d'ou le KeyError qu'on obtient en +lui donnant un bundle publie. + +Le principe reste le meme et il est explique en detail dans l'autre script : on +n'essaie pas de faire cohabiter Metal et DirectX dans la MEME passe, parce que +les tables de liaison de parametres y sont partagees et que Metal eclate les +globals en VGlobals/FGlobals. On empile un SubShader entier, qui arrive avec ses +propres tables. + +Interet ici : le bundle publie garde ses maillages, ses textures et ses +materiaux bit pour bit. C'est la seule voie pour les 8 orphelins, dont on n'a +pas les sources. + +Usage: + graft_metal_2019_1.py +""" + +import sys + +import UnityPy + +GPU = {4: "d3d11", 9: "gles3", 14: "metal", 15: "glcore", 18: "vulkan"} +FLAT_TABLES = ("offsets", "compressedLengths", "decompressedLengths") + + +def shaders_by_name(env): + out = {} + for obj in env.objects: + if obj.type.name != "Shader": + continue + tree = obj.read_typetree() + out[tree["m_ParsedForm"]["m_Name"]] = tree + return out + + +def check_flat(tree, label): + """Garde-fou : refuser un bundle qui n'est pas au format plat 2019.1.""" + if "stageCounts" in tree: + raise SystemExit(f"{label}: format Unity 6 (stageCounts present), utiliser merge_subshader_platforms.py") + for key in FLAT_TABLES: + if tree[key] and isinstance(tree[key][0], list): + raise SystemExit(f"{label}: table '{key}' imbriquee, ce bundle n'est pas en 2019.1") + + +def graft(base_tree, donor_tree): + """Ajoute les plateformes du donneur absentes de la base. Renvoie leurs noms.""" + added = [] + blob = bytes(base_tree["compressedBlob"]) + + for i, platform in enumerate(donor_tree["platforms"]): + if platform in base_tree["platforms"]: + continue + shift = len(blob) + base_tree["platforms"].append(platform) + base_tree["offsets"].append(donor_tree["offsets"][i] + shift) + base_tree["compressedLengths"].append(donor_tree["compressedLengths"][i]) + base_tree["decompressedLengths"].append(donor_tree["decompressedLengths"][i]) + blob += bytes(donor_tree["compressedBlob"]) + added.append(GPU.get(platform, platform)) + + if added: + base_tree["compressedBlob"] = list(blob) + base_tree["m_ParsedForm"]["m_SubShaders"].extend(donor_tree["m_ParsedForm"]["m_SubShaders"]) + + return added + + +def main(base_path, donor_path, out_path): + base_env = UnityPy.load(base_path) + donors = shaders_by_name(UnityPy.load(donor_path)) + for name, tree in donors.items(): + check_flat(tree, f"greffon/{name}") + + total = 0 + for obj in base_env.objects: + if obj.type.name != "Shader": + continue + tree = obj.read_typetree() + name = tree["m_ParsedForm"]["m_Name"] + if name not in donors: + print(f" {name}: absent du greffon, laisse tel quel") + continue + + check_flat(tree, f"publie/{name}") + before = [GPU.get(p, p) for p in tree["platforms"]] + nsub_before = len(tree["m_ParsedForm"]["m_SubShaders"]) + added = graft(tree, donors[name]) + after = [GPU.get(p, p) for p in tree["platforms"]] + nsub = len(tree["m_ParsedForm"]["m_SubShaders"]) + + if added: + obj.save_typetree(tree) + total += len(added) + print(f" {name}: {before} -> {after}, SubShaders {nsub_before} -> {nsub}") + + if not total: + print(" rien a greffer") + return 1 + + with open(out_path, "wb") as fh: + fh.write(base_env.file.save(packer="lzma")) + print(f" ecrit {out_path}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print(__doc__) + sys.exit(1) + sys.exit(main(sys.argv[1], sys.argv[2], sys.argv[3])) diff --git a/mac-patcher/tools/install_dual_to_cache.py b/mac-patcher/tools/install_dual_to_cache.py new file mode 100644 index 000000000..6248eef06 --- /dev/null +++ b/mac-patcher/tools/install_dual_to_cache.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Installe les bundles bi-plateforme dans le cache TTS local, pour une repetition generale. + +Permet d'essayer le fix a l'echelle du mod entier, en jeu, sans rien heberger ni +pousser : TTS lit son cache avant de telecharger, donc un fichier depose au bon +nom est servi tel quel. + +Nom de cache : TTS reecrit l'URL du mod (cloud-3.steamusercontent.com) vers +l'hote Akamai, puis aplatit l'URL en ne gardant que l'alphanumerique. Verifie +contre les fichiers deja presents dans le cache de Martin. + +Ne touche qu'aux bundles qui embarquent un shader custom ET dont un rebuild +existe (colonnes du CSV d'inventaire). Sauvegarde systematique avant ecriture. + +Usage: + install_dual_to_cache.py --dry-run # montre ce qui serait fait + install_dual_to_cache.py --install # installe (TTS doit etre ferme) + install_dual_to_cache.py --restore # remet les originaux +""" + +import argparse +import csv +import os +import re +import shutil +import sys + +CSV = os.environ.get("INVENTAIRE", "inventaire-bundles.csv") +DUAL = os.environ.get("DUAL_DIR", "UnityProject-U6/AssetBundles-dual") +CACHE = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles") +BACKUP = "bundle_backups/cache-avant-dual" + +# Bundles dont le rebuild a DERIVE du publie (cf compare_published_vs_rebuild.py +# et memoire registre-valide-invalide V15) : les installer introduirait une +# regression visible, sur Mac comme sur Windows. Ils restent donc cassés sur Mac +# tant qu'on ne les repare pas autrement (greffe dans le bundle publie). +DERIVES = { + "stunt_double_bot_1", # le rebuild ajoute un socle base_small + Default-Material absents du publie +} + + +METAL = 14 + + +def is_ours(path): + """Vrai si ce bundle porte une variante Metal, donc s'il sort de chez nous.""" + try: + import UnityPy + except ImportError: + return False # sans UnityPy on garde l'ancien comportement + try: + for obj in UnityPy.load(path).objects: + if obj.type.name == "Shader" and METAL in obj.read_typetree()["platforms"]: + return True + except Exception: + return False + return False + + +def cache_filename(url): + """Nom sous lequel TTS met ce bundle en cache.""" + akamai = re.sub(r"^https?://[^/]+", "https://steamusercontent-a.akamaihd.net", url) + return re.sub(r"[^a-zA-Z0-9]", "", akamai) + ".unity3d" + + +def targets(): + """[(nom_bundle, chemin_source, chemin_cache)] pour les bundles a remplacer.""" + out = [] + with open(CSV, encoding="utf-8") as fh: + for row in csv.DictReader(fh): + if row["besoin_fix"] != "oui" or row["rebuild_dispo"] != "oui": + continue + if row["bundle"] in DERIVES: + print(f" ⊘ {row['bundle']} : rebuild derive du publie, ecarte") + continue + source = os.path.join(DUAL, row["bundle"]) + if not os.path.exists(source): + print(f" ⚠ {row['bundle']} : fusion absente de {DUAL}, ignore") + continue + out.append((row["bundle"], source, os.path.join(CACHE, cache_filename(row["url"])))) + return out + + +def install(rows, dry_run): + os.makedirs(BACKUP, exist_ok=True) + installed = saved = 0 + + for name, source, dest in rows: + if dry_run: + state = "remplace" if os.path.exists(dest) else "depose" + print(f" {state:9s} {name:38s} -> {os.path.basename(dest)[:52]}") + continue + + # Sauvegarde de l'original AVANT toute ecriture, une seule fois : + # relancer l'installation ne doit pas ecraser la sauvegarde par un fichier deja modifie. + # + # ⚠ "une seule fois" ne suffit PAS, et ca s'est produit le 12/08 : un + # bundle DEPOSE au premier passage (absent du cache, donc rien a + # sauvegarder) existe au second, et se fait sauvegarder alors que c'est + # deja le notre. 60 des 114 entrees etaient dans ce cas. Comme aucun + # bundle publie ne porte de variante Metal, en trouver une est la preuve + # qu'on a affaire a notre propre travail : on ne sauvegarde pas. + if os.path.exists(dest): + keep = os.path.join(BACKUP, os.path.basename(dest)) + if not os.path.exists(keep) and not is_ours(dest): + shutil.copy2(dest, keep) + saved += 1 + + shutil.copy2(source, dest) + installed += 1 + + if dry_run: + print(f"\n{len(rows)} bundles seraient installes (aucune ecriture faite)") + else: + print(f"\n{installed} bundles installes, {saved} originaux sauvegardes dans {BACKUP}") + + +def restore(): + if not os.path.isdir(BACKUP): + print(f"aucune sauvegarde dans {BACKUP}") + return 1 + count = 0 + for entry in os.listdir(BACKUP): + shutil.copy2(os.path.join(BACKUP, entry), os.path.join(CACHE, entry)) + count += 1 + print(f"{count} bundles d'origine restaures dans le cache") + # Les bundles qui n'etaient pas en cache avant restent en place : ils ne + # remplacent rien, TTS les aurait telecharges de toute facon. + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--dry-run", action="store_true", help="montre sans rien ecrire") + group.add_argument("--install", action="store_true", help="installe dans le cache TTS") + group.add_argument("--restore", action="store_true", help="remet les originaux") + args = parser.parse_args() + + if args.restore: + return restore() + + rows = targets() + if not rows: + print("rien a installer") + return 1 + + print(f"{len(rows)} bundles a shader custom, fusion disponible\n") + install(rows, args.dry_run) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mac-patcher/tools/install_grafted_to_cache.py b/mac-patcher/tools/install_grafted_to_cache.py new file mode 100644 index 000000000..d5f45f187 --- /dev/null +++ b/mac-patcher/tools/install_grafted_to_cache.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Installe les bundles GREFFES (orphelins) dans le cache TTS local. + +Pourquoi pas install_dual_to_cache.py : celui-ci ne prend que les bundles dont +un rebuild existe (colonne rebuild_dispo du CSV), ce qui exclut par construction +les 8 orphelins — ils n'ont pas de sources, c'est toute la raison de la greffe. + +Sauvegarde dans un dossier DISTINCT de cache-avant-dual, pour ne pas melanger +les deux campagnes et pour qu'une restauration de l'une ne defasse pas l'autre. + +Garde-fou : on refuse d'ecraser un fichier du cache qui contient DEJA une +variante Metal — ce serait le signe qu'on repasse sur notre propre travail et +qu'on sauvegarderait une copie greffee comme si c'etait l'original. + +Usage: + install_grafted_to_cache.py --dry-run + install_grafted_to_cache.py --install # TTS doit etre ferme + install_grafted_to_cache.py --restore +""" + +import argparse +import csv +import hashlib +import os +import re +import shutil +import sys + +import UnityPy + +CSV = os.environ.get("INVENTAIRE", "inventaire-bundles.csv") +GRAFTED = os.environ.get("GRAFTED_DIR", "tts-assets/grafted") +CACHE = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles") +BACKUP = "bundle_backups/cache-avant-greffe" +METAL = 14 + + +def cache_filename(url): + """Nom sous lequel TTS met ce bundle en cache (meme regle que l'autre installeur).""" + akamai = re.sub(r"^https?://[^/]+", "https://steamusercontent-a.akamaihd.net", url) + return re.sub(r"[^a-zA-Z0-9]", "", akamai) + ".unity3d" + + +def has_metal(path): + for obj in UnityPy.load(path).objects: + if obj.type.name == "Shader" and METAL in obj.read_typetree()["platforms"]: + return True + return False + + +def targets(): + """[(nom, source_greffee, chemin_cache, url)] pour les bundles greffes disponibles.""" + out, seen = [], set() + with open(CSV, encoding="utf-8") as fh: + for row in csv.DictReader(fh): + name = row["bundle"] + if name in seen: + continue + source = os.path.join(GRAFTED, name + ".unity3d") + if not os.path.exists(source): + continue + seen.add(name) + out.append((name, source, os.path.join(CACHE, cache_filename(row["url"])), row["url"])) + return out + + +def main(): + ap = argparse.ArgumentParser() + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--dry-run", action="store_true") + g.add_argument("--install", action="store_true") + g.add_argument("--restore", action="store_true") + args = ap.parse_args() + + if args.restore: + if not os.path.isdir(BACKUP): + sys.exit(f"pas de sauvegarde dans {BACKUP}") + n = 0 + for f in sorted(os.listdir(BACKUP)): + shutil.copy2(os.path.join(BACKUP, f), os.path.join(CACHE, f)) + n += 1 + print(f"{n} fichiers restaures depuis {BACKUP}") + return + + rows = targets() + if not rows: + sys.exit(f"aucun bundle greffe dans {GRAFTED}") + + todo, skipped = [], [] + for name, source, dest, url in rows: + # Absent du cache = unite jamais posee sur la table par ce joueur. On + # depose quand meme : TTS lit son cache avant de telecharger, donc le + # fichier sera servi tel quel au premier spawn. Rien a sauvegarder, + # il n'y a rien a ecraser. + if not os.path.exists(dest): + todo.append((name, source, dest, "depose")) + continue + # Aucun bundle PUBLIE ne porte de Metal — c'est tout le probleme qu'on + # repare. Donc un fichier de cache qui en porte est forcement l'un des + # notres : on le remplace sans le sauvegarder, sinon la sauvegarde + # finirait par contenir notre propre travail au lieu de l'original. + if has_metal(dest): + todo.append((name, source, dest, "rejoue")) + continue + todo.append((name, source, dest, "remplace")) + + for name, source, dest, action in todo: + print(f" {action:9s} {name:45s} -> {os.path.basename(dest)[:40]}...") + for name, why in skipped: + print(f" ⊘ {name:45s} {why}") + print(f"\n{len(todo)} a installer, {len(skipped)} ecartes") + + if not args.install: + return + + os.makedirs(BACKUP, exist_ok=True) + for name, source, dest, action in todo: + if action == "remplace": + keep = os.path.join(BACKUP, os.path.basename(dest)) + if not os.path.exists(keep): + shutil.copy2(dest, keep) + shutil.copy2(source, dest) + print(f"installe {len(todo)} bundles, originaux ecrases sauvegardes dans {BACKUP}") + + +if __name__ == "__main__": + main() diff --git a/mac-patcher/tools/inventaire-bundles.csv b/mac-patcher/tools/inventaire-bundles.csv new file mode 100644 index 000000000..d7d04d61e --- /dev/null +++ b/mac-patcher/tools/inventaire-bundles.csv @@ -0,0 +1,191 @@ +url,role,origine,bundle,shaders,besoin_fix,rebuild_dispo,note +http://cloud-3.steamusercontent.com/ugc/1009315641457799750/AE83DC038BA8AAF48678D07E1F9734D4A9944C1D/,principal,telecharge,arc_1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457800134/88E2FEC812AD5E7B7570836120992252AC758BF8/,principal,telecharge,arc_2,Particles/Standard Unlit,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457800538/06BBAAAAC7FB68DAA9351F12008475068A96BFFB/,principal,telecharge,arc_3,Particles/Standard Unlit,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457800878/34EC84D7ECD99E073417866A9BEC4288E00F5359/,principal,telecharge,arc_dc15x,Particles/Standard Unlit,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457801321/CED1CD80F307683E79B9A4ED87CECAED7C070EBF/,principal,telecharge,arc_echo,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457802221/506D704E232C1BFD85F063A24EBF24C5914C2C40/,principal,telecharge,arc_fives,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457802621/01F605D855048F823A0ECA6C46D2A62B56C04124/,principal,telecharge,arc_leader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457845802/AE70766EAA85C875B023FB980A5A5CEEFB98B1D9/,principal,cache,bx_droid_1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457846188/D7F348E890430AA319DDDFA513D99DC2783ECF91/,principal,cache,bx_droid_2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457846574/F169D9E2E45B73D1E21B17E32D3B4B4D9AACB196/,principal,cache,bx_droid_3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457847081/F7D4E8D3EC85BAEEB5C1B5B913B7F484CC28FC10/,principal,cache,bx_droid_leader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457847567/079157D001A1520085C6A30620750817C6C01BFF/,principal,telecharge,bx_droid_saboteur,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1009315641457847963/0EA6BE014DA494689FF32F9570C6A73CD88F1894/,principal,telecharge,bx_droid_sniper,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1617345381346377755/75D0ACFDFFCA5A08D7CAB59DA941B226D1563D46/,principal,telecharge,yoda,BucketheadBits/Units/Glow Geometry,oui,non, +http://cloud-3.steamusercontent.com/ugc/1618437692581074585/1C74BEB92DC42D3585BFC1185A77DE14EC3249CA/,principal,telecharge,isf_delmeeko,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618437692581075349/6265F81E1BAA090FFA813270A8E3126CF8AD6CC5/,principal,telecharge,isf_gideon_hask,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238122996093/C79160007FD884C2B8737DCA888D61C955073141/,principal,telecharge,isf_1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238122996656/AAD706C50446522C98259A20C03E757C4F7B98B1/,principal,telecharge,isf_2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238122997115/F100B28E5DBF4A56FB22D3410270BEE4EF344FEF/,principal,telecharge,isf_3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238122999341/C1AEEE50112F060BA8E5CAFBC16F417A0EF7C3C0/,principal,telecharge,isf_inferno,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238122999812/779C9D69CA440F970DD0B7C6394A812732F9C588/,principal,telecharge,isf_leader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238123000906/C64EAD02CA22F8BFB59320925DAD7D89BA1E7AD6/,principal,telecharge,isf_t21,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1618438238123043414/B7E9660E470DC070545782406C29F1E1356A5685/,secondaire,telecharge,isf_materials,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1619598366706113202/449F19DA94D42DAD0F0B4D0B77A8E9AD45C320E5/,principal,telecharge,std_kraken,,non,non, +http://cloud-3.steamusercontent.com/ugc/1619598366706441824/97EB6FA096B824294EE48FE762380CCBE866F3E2/,principal,telecharge,std_kalani,,non,non, +http://cloud-3.steamusercontent.com/ugc/1619598366706441991/04B29BB48A98AF8D79FC1414548496BAD49CCB28/,secondaire,telecharge,std_materials,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1619598366708042667/2DABB5788E51006EDB3FCB03400ACD4CE07755F3/,principal,telecharge,wookiee_warriors_long_gun_wookiee_model,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1619598366708043779/0D96CE1619F4B27F583257A366431E3BAD428F19/,principal,telecharge,kashyyyk_defenders_leader_model,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1619598366708043883/1EA1B3C21A50C20D4D168AB91ECFE1C01A4F7DF5/,principal,telecharge,kashyyyk_defenders_model,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1619598523372467360/2810096383D51B0939DC3FFCBC83155C6D08CB53/,principal,telecharge,wookiee_chieftain_model,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1619598523372505515/1191E8089B37C0CC4BBE8283EF6B7F9C5A74A13C/,principal,telecharge,fluttercraft,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1619598523372574861/CCF68AF1C35F52A714AB0344FCB170F5BAA1672A/,principal,telecharge,infantry_support_platform_model,BucketheadBits/Units/Color Replacer,oui,non, +http://cloud-3.steamusercontent.com/ugc/1621849308236386470/4D5227051DEEED77303985CE6D34CDBFB27FD472/,principal,telecharge,ig-100_magnagaurd_1_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1621849308236386513/AC53B9F503C0B353AE7AA797072F7EBA15A1214C/,principal,telecharge,ig-100_magnagaurd_2_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1621849308236386555/F47418A458CF12C80756A80B842817EA1CCC62CF/,principal,telecharge,ig-100_magnagaurd_3_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1621849308236386662/7ABB72B5A8A301611033E4A424FD61271024FB4C/,principal,telecharge,ig-100_magnaguard_electrowhip_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1621849308236386731/FA50237A0BF717ED06EA3020D5F4089D452ADCD6/,principal,telecharge,ig-100_magnaguard_leader_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1621849308236386787/E93D6E65A61C9262B5934C5A419736B45B50D75B/,principal,telecharge,ig-100_magnaguard_rps-6_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1621849308236386933/DCD62751F06157E85CE575C8BB1CCF86379BB7DE/,principal,telecharge,wookiee_warriors_battle_shield_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1621849538641208262/A0E48F7D9C94617C81BAD4D649717BAC7A8B9B64/,secondaire,telecharge,ig-100_magnagaurd_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1621849538645161033/A69093521055BB5CA008D3DDE5F6EBEB990D0AF2/,principal,cache,dsd-1_dwarf_spider_droid_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1686023073179090715/187094FD9EBAFB6FF44A65F2D411D68154C0FAC7/,principal,telecharge,t47_airspeeder_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1686023445067146269/A9189AC12193D3BD24624F7A5137C4E69FADDBBF/,principal,cache,droideka,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1686023445067146631/305531F34B6598A008F5925D828457DF1A8195F6/,secondaire,telecharge,droideka_fx,BucketheadBits/Units/Glow Geometry | BucketheadBits/Units/Rim Glow Geometry Shield,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1686023445067168845/FBADA8EDE2ED248CA7F50C1DB3BFF46215E0360B/,principal,cache,droideka_leader,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845389/0D7DFE0C5BF5CDD527A619B75A25A2177D417BD0/,principal,telecharge,pyke_syndicate_foot_soldiers_trooper_3_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845475/01738B0D52E2D9C2870D6E33A2D05ECD12201C85/,principal,telecharge,black_sun_enforcers_leader_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845513/BB3CD9624D6F43250D0406D099819E497A1A70BE/,principal,telecharge,pyke_syndicate_foot_soldiers_electrowhip_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845548/FD104F41509A53EA1C888862A3C5F0B98F609F1F/,principal,telecharge,pyke_syndicate_foot_soldiers_leader_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845607/4B1530CF96F9DB8F172B02676F1D8B347CFD862A/,principal,telecharge,pyke_syndicate_foot_soldiers_p13-m_disruptor_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845646/BE0DEED782F96BD44C389DEDEA41EBEA0C7A6D04/,principal,telecharge,pyke_syndicate_foot_soldiers_trooper_1_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1745728387706845697/15B6DCCA8D8D76E1CB78219C58790E40DDD7CE8A/,principal,telecharge,pyke_syndicate_foot_soldiers_trooper_2_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1745728519554838398/33764317D1BE774E1F978082C1A97681C1183C01/,principal,telecharge,boba_fett_the_old_and_wise_model_1,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1747939379132611640/1EEBEB993635A56525283FAFA0D62D347FB3121C/,principal,telecharge,laat_le,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1785135125810942830/666860D7C34C83C500C62BDDE115F30C59A42C74/,principal,telecharge,darktrooper_leader,BucketheadBits/Units/Glow Geometry | BucketheadBits/Units/Rim Glow Geometry Shield,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1785135125810942938/E77EC5970DA310A2E1D96F796E98D82813AE0891/,principal,cache,darktrooper_trooper1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1785135125810943021/4B5888D461F54C2C281B108233D1F1D8D8C2A679/,principal,telecharge,darktrooper_trooper2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1785135165610974498/47B1C8B92A0E945388F7773EAB2F73B0A81E761E/,principal,telecharge,grogu_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1789640371717200769/60353F40BAE6D92D6F6ED24E2883EB9CC2EA74C4/,secondaire,telecharge,mauldelorian_cpt_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1789640371717200823/B886D3157AA2F2528F4E5F76F26DF44A41496615/,secondaire,telecharge,mauldelorian_trooper_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1789640371719006177/3A456EB29012BFBE4322E020CBC08CF6F915BBF3/,principal,telecharge,mauldelorian_rook_kast_model,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry | BucketheadBits/Units/Rim Glow Geometry Shield,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1789640371719014130/7A59CCE5B8A8C87DD3952C15819FAA8921708A75/,principal,telecharge,maul_a_rival_model,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry | BucketheadBits/Units/Rim Glow Geometry Shield,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1789640371719058670/9E6B82A0BF8367F2C6D369BD3464DA5E9D0C7917/,principal,telecharge,ig-88_model,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1802024425338475441/730DE7AD6E1A55EA9F9264DADC1DDC0AC2CCA565/,principal,telecharge,aa5_scan,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1802024425338475527/29D3660FA84E5B9F804B840C18E68F5631EE6462/,principal,telecharge,aa5_scan_shadow,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143285/F206D67AFF9379B68AD0795767DA378CE80D44AE/,principal,telecharge,mauldelorian_trooper_2_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143328/3D6F3C2E8DFB78B9B748FFC8F339B9C568D7408E/,secondaire,telecharge,black_sun_enforcers_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143366/41AB7DFFD2FCF20E2BCC3F899F728863C30F6343/,principal,telecharge,mauldelorian_gunsligner_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143408/F4A88228E11334545EACA86B36E6B2008E76B0D3/,principal,telecharge,mauldelorian_leader_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143451/3DFD9C8BD5D504D3A2F7934678D1A0A7C2285863/,principal,telecharge,mauldelorian_marksman_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143500/4BDEA7F5B523FC06641B3D71D1E112DC14BD7F81/,principal,telecharge,mauldelorian_trooper_1_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143543/CBAC94E0D1731CB8D1034D24F58987E854A8596A/,secondaire,telecharge,pyke_syndicate_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143649/BBBA0D26FB46EAD96421C4B77FE779B5F0F848AA/,principal,telecharge,black_sun_enforcers_mag_det_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143685/C75388115AFCF42ACC79E2C16FEF36755C975AA0/,principal,telecharge,black_sun_enforcers_scattergun_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143724/48B5A35E10B4977E255D889A8FCAEA8C980A11A9/,principal,telecharge,black_sun_enforcers_trooper_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143763/E93BA6716A05CA95AE2EA3B7C0DFD8AF145D5C22/,principal,telecharge,black_sun_vigo_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420143862/CF491C11176E40BD786A89E1D0C20D3316448188/,principal,telecharge,gar_saxon_model,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1856061112420351237/706DEA645FEE2EC9787F05F4FF2926632E2C4894/,principal,telecharge,pyke_capo_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1924743543116067434/0A068914BDD35AE6F63D7E29EBC7E0B5F712DA1F/,principal,telecharge,swoop _bikes_leader_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1924743543116067490/87719312BE33C493B97B0915DB8C89FDC57D3F38/,principal,telecharge,swoop_bikes_rider_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/1924743543116068132/37BCE99DCF06BEFA1119FEA911188695F9C48079/,principal,telecharge,nr-n99_persuader-class_tank_droid_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2004698291313160948/23CA698C8F2AF1C08D2DED3564B138E72E2ABCBF/,principal,telecharge,darktrooper_hvyminigun,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2004698291313161010/1873BCD554A553BC2FF25A2BE16116EDEBFDCEF4/,principal,telecharge,darktrooper_hvyrocket,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2004698291313161066/7BD340C7AAB594CDA1644D900F606FEC8A0165A1/,principal,telecharge,darktrooper_hvysword,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2004698291313176203/E77892ADB668EA0CB548F6ED71BE44CA3FB730E7/,secondaire,cache,darktrooper_mat,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128356/9E8C70962F9D5BF853BD9310F2BB1E1A3F80DB5D/,principal,telecharge,ewok_skirmisher4,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128445/EBB56734DB6527B3566C317F6F4F0E84471D6776/,principal,cache,ewok_skirmisher5,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128506/B24F2402EDC36C64688B7F492B382D972ABA30F1/,principal,cache,ewok_slinger1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128565/24D88975D67E46B5FA9294EB6DCC146912D61F2F/,principal,telecharge,ewok_slinger2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128627/19AC5B05F3A355226EE783B9F239B3B285EA077A/,principal,telecharge,ewok_slinger3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128715/CB52DA6C0FC1DF3511C2A2D62CB2B3A13A9D4F8F/,principal,cache,ewok_slinger4,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128778/1D5BCB551375F1A7988C434F0B52E18434BB4747/,principal,telecharge,ewok_slinger5,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128844/558E1277513B0AF48911A68B5FAAB981BC0A56A1/,principal,telecharge,ewok_trapper,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128895/152E8A0BE0A0FB98FAD48338552C6282DD8954B7/,principal,telecharge,ewok_axe,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867128972/9388116F0C7BE70B991CC7715943BF9FF08D3890/,principal,telecharge,ewok_leader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867129049/686925E47D218727E1C79A25E0F44FC80FAEE43F/,secondaire,telecharge,ewok_sharedmat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867129131/D612F1B1EBDA3172B69BEE40159CE5D999045A62/,principal,cache,ewok_skirmisher1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867129196/6EED0C8AAF8F50751EE4206471D1273B6C0F4B5A/,principal,telecharge,ewok_skirmisher2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867129252/FC0A6247B6AB21A99919659C1F9069186C6D82ED/,principal,cache,ewok_skirmisher3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2008089545867129309/3D6C219F243BDE984C42BB3444B01DB09B522CBB/,principal,telecharge,logray_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650140825/D8AE1D1F27B97F359916236DA163DA7EC4B517C1/,principal,telecharge,storm4,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650140913/ADEED3994E6F70777B939A89B18FF520765DCDC4/,principal,telecharge,stormcpt,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141001/FF62545FC3DEF499BA8A0C04F07917C6CAA9E30D/,principal,telecharge,stormdlt,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141088/7342269A721DF49D0C38F1469E8463093734EF66/,principal,telecharge,stormhh12,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141156/BF26D516EA8F33C72822FF9AB369FF7CA444C403/,principal,telecharge,stormleader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141221/5D4DAEED517F52EE2ECBC631301C97422F519AB9/,principal,telecharge,stormrt97,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141284/6D59924E68B2B1640C0AC479B8DE95F434056650/,principal,telecharge,stormspec,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141339/1EB723D5FCF6655CFFF7EF700EE7108274E1A6C7/,principal,telecharge,stormt21,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141545/30BEFE4E87E379AA361AFF5FD24A8F5D3F0BA4C0/,principal,telecharge,storm1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141624/1366A6A4D84D9FE597EEA2736AFC187F392418DA/,principal,telecharge,storm2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141692/8E06789B4D2003055108ED85D76E875729254757/,principal,telecharge,storm3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2017090166650141746/A8FD77EB51C50C64CD924BDAEEDB70D8839E0EF3/,secondaire,telecharge,stormtrooper_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2022718494181455957/4BB3E42AA412A7ED2ED6F26BFDFA25414F127B71/,principal,telecharge,assajventress,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2023850162084443471/517A8997FB85780B040DD3017147753F438137CC/,principal,telecharge,ahsoka,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2023850162090106066/B6E661CAB8049A1B1CE975B53BB57B337E2C5DEE/,principal,telecharge,wicket_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177195589/5E52A1E47746FF179ED6327F681D51D92C47D1D5/,principal,telecharge,bugsonic,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177195724/233E75C81DB511291B2B95015510DF1B48B2A8DB/,principal,telecharge,sunfac,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177195807/C1E3DCF8DC0772F56B22E42E89DECC9F30110E8D/,principal,telecharge,bugpike,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177195881/84020A8311904050DC9309FE9109D47BC41FE4AA/,secondaire,telecharge,bugshared,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177195973/1D70D4C8D0C6A06E14A7A875228E3CD49D5E0807/,principal,telecharge,poggle,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177201554/7AEA5B0A98A298731F6BE51591401BBF095A5797/,principal,telecharge,bug1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177201933/E0DBF0D812FFF4F7F3A77CF1E4EAB84F78B8C49E/,principal,cache,bug2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177202012/76CB0371503304D5AAF562E5E2F04961CD206090/,principal,telecharge,bug3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2058763770177202081/37F68E146C49B315AA46E15BE88DDB56AE97646C/,principal,telecharge,bugleader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809184624/F2826DD52329584D1C3B3795B0E55948EA96B467/,principal,telecharge,cloneengineer,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809184711/BC2EE95F62E043F83A352F825B6A041AC6D59959/,principal,telecharge,clonemedic,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809184812/3A020CF4B5983E1AF55F65A3F8F7BDCBAAEAAACC/,principal,telecharge,clonetechnician,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185328/4A6DA42CDB70EF979D5B8B675CEFA5459FA60D4E/,principal,telecharge,p2_mortar,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185513/A794F69FD4F7FF6093A8E4564633AF49C95DE79D/,principal,telecharge,drksithprobe,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185590/81774E94925CD5B15B7CE0DC52DEC465D9774024/,principal,telecharge,maul,BucketheadBits/Units/Color Replacer | BucketheadBits/Units/Glow Geometry,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185654/E58BE2AB7BC859E1A896FF9B9909B6976E69CF4B/,principal,telecharge,pkworker,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185719/FF120BC2FA5DC8258021E2849C2B81591CCA36B9/,principal,telecharge,tseries,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185784/C7B137C19E24986DBE1E9A2712676AB320E4B2A9/,principal,telecharge,viper,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185852/7940F0BEFCFE905039054DA1CA3BBF3C0CB2C7A9/,principal,telecharge,clanwrenchump,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809185988/83F7A4664576B192905C6393A6D9A5DAA776E01A/,principal,telecharge,tristanwren,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186071/A6DB57CF6BA2EEDAB6EB8A719DB8D0702708C759/,principal,telecharge,ursawren,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186179/35024924D8BC8A727F508557AF7B714D2E3D22FF/,principal,telecharge,mandoresistance_2,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186272/7872209AB5CBE104129F9AFC58267EB8F6CF80D1/,principal,telecharge,mandoresistance_1,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186354/387C36C2BB545E6CC882A05C0D81F5BE164166B7/,principal,telecharge,clonecommander,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186437/C255A6920C6B62ADD6B948311053BAAE4803BE29/,principal,telecharge,mandoresistance_leader,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186518/DCA8031C62A867134FAA814D4976978058C01369/,principal,telecharge,lando,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809186599/4B154E18EF2C0ADE6C347FC4B15AD856D00546DF/,principal,telecharge,sabine,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446809252570/7F009B812ED06B7E45CFCE48142293A4B9C77991/,principal,telecharge,mandoduelist,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446813135349/81275DD398B659088A018A09394B7FD6E5C3B279/,principal,telecharge,waxer,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446813135454/32F343B3295B563DF3041AF5F433E151F88C66B6/,principal,telecharge,boil,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2174736446813135529/6A6C874DBCA77E800A77C20FD36C2F223661CD10/,principal,telecharge,cody,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2202884666993392985/D48A26F5F9D3498814911131BA08B0BAF92F8B65/,principal,telecharge,atrt_gar,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696859319/2846289D99E3CA2DC68F2BB8FC69ECF1ABE2072E/,principal,telecharge,delta_07,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696859485/0340EC3130B99413F05F2CB2544E9FDE79A3871D/,principal,telecharge,delta_40,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696859554/12CFFC76B71288B5426C2E57CC8A91F515B075F2/,principal,telecharge,delta_62,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696859651/A77E973493F46183AC97F68C1A191EC0E74F6A65/,principal,telecharge,delta_leader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696882381/265198067C41DF8333A609EDCE831A547A68F28F/,secondaire,telecharge,delta_38mat 1,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696882497/A846281F67580B208A2BBBDADE7C601230486E20/,secondaire,telecharge,delta_40mat 1,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696882580/BE0E56B70A5D6B576BA4AAB83FDB1DB2BD835887/,secondaire,telecharge,delta_62mat 1,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2474241114696882664/1B6168DA14AFA3A40CC823399CC4072FD1B7E958/,secondaire,telecharge,delta_07mat 1,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2475370743175243825/0B07462AB1D02ACB64262C1E875E9DC5E3719850/,principal,telecharge,echo_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2475370743175243916/EFBD0137414E802232F64B02C78A9D9275C9C1CB/,principal,cache,hunter_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2475370743175243961/93E8549BC0F1F88228D651FAE226459945DC6651/,principal,telecharge,omega_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2475370743175244098/67CBDAB9CA48B24940FC743CEB778A526C990993/,principal,telecharge,tech_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2475370743175244156/4986205CB2289E6CF72269D9A17E6B2D405968DA/,principal,telecharge,wrecker_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2475370743175244300/863B36B154CA2A32E4C36B9CFBC47984846ECE3C/,principal,telecharge,crosshair_model,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682582649/F03C73249983DDC1178314FD61C7D53DA27840EE/,secondaire,telecharge,range_trooper_mat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682582714/3AFE8876506FE4B261417033A0E804BA9146FECE/,secondaire,telecharge,rep_commandomat,BucketheadBits/Units/Color Replacer,oui,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669442/778C5CDC3ED0EF8036784484685D9C1E283AE229/,principal,telecharge,commando_1,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669514/964CC0732F05CF777C4F226E4A66B82C9B8F17F1/,principal,telecharge,commando_2,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669589/FE6E365D8DFE4186F7AE93CBEF854B84881DFA18/,principal,telecharge,commando_3,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669631/743A3F4EE203D22A0C7A8324994F8DD6D15337C0/,principal,telecharge,commando_leader,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669687/1A75297DAE22B6DFE46AF5B69EDC87652B23957F/,principal,telecharge,range_trooper_leader_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669758/7E6C79BDA776AD0618FF66FCD961C59DD6B0F4F1/,principal,telecharge,range_trooper_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669844/3306C99E1B2A23502CA35EEB63FC842EA7FB4E3A/,principal,telecharge,range_trooper_dlt-20a_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/2495630600682669896/C2F5892D64B3B5CE33A83A3F874C1A66594F6412/,principal,telecharge,range_trooper_t-21a_model,,non,oui, +http://cloud-3.steamusercontent.com/ugc/773995200348530664/5E248091F8CC37B1023257338D4947E4ADFE48AB/,secondaire,telecharge,arc_materials,,non,oui, +http://cloud-3.steamusercontent.com/ugc/773995528924546789/8774CBBDC3C4F18B11A444CC96EA5FE7F00DB48F/,secondaire,telecharge,bx_droid_materials,,non,oui, +https://steamusercontent-a.akamaihd.net/ugc/14648332786386280089/CFF5EDB1D4A6A8136BEC1997DEBF668DB0166B48/,principal,telecharge,ahsoka commander,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/15416532958990284151/F41C3DBAFCDCA9717ED5F6A53E6E46F1BFABA467/,principal,cache,ahsoka padawn,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/16432158164944196/4594ADE002844CEEB6FB9A75E17AA802FCAE89FF/,principal,telecharge,crab 1 variant,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2446089105226763008/9B5002BC5C049F340FF7CDBC875F5053A5DB2574/,principal,telecharge,djin djarin 10k,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2446089105227025807/2E7D8F479762060CAF447A2E5A6C27891C314EA2/,principal,telecharge,moff gideon 10k,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2446089105227059084/0EB4EE32683338BC754DA5EC1F613ABA71048E90/,principal,telecharge,kallus 10k,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2446090001278329724/E8E229E0E3FB6230D9F5A1823C71032A0FC9F2D0/,principal,telecharge,7th sister,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2456221571853672166/B86F663B609267644D837B4F18C59614C6785229/,principal,telecharge,5th test,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2478747809407737751/27A207BFDA271D2EA267D400CBDF551B11EE093C/,principal,telecharge,legion stormtrooper,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2478747809413608082/BD8A164AAD4C70AE2F8CB8165F3D2DC9FD94C778/,principal,telecharge,legion storm trooper leader,,non,non, +https://steamusercontent-a.akamaihd.net/ugc/2482129948495931625/A14C0E047A0C7C8D12B96B34A16D2DAAA3A5E9E3/,principal,cache,poi_token,,non,oui, +https://steamusercontent-a.akamaihd.net/ugc/2491137781649821412/A274C8B3E646E0EBFB81D282570C5A1C6D263F2B/,principal,telecharge,riot_2,,non,oui, +https://steamusercontent-a.akamaihd.net/ugc/2491137781649821497/585AF58548561265B23B01A56577DE0732464636/,principal,telecharge,riot_3,,non,oui, +https://steamusercontent-a.akamaihd.net/ugc/2491137781649821557/0B4EFC2CDDBC7BB72768141562946583278CAAD6/,principal,telecharge,riot_4,,non,oui, +https://steamusercontent-a.akamaihd.net/ugc/2491137781649821645/5FF305DCBED87DDE0DC92AB430557E501D5CDFC2/,principal,telecharge,riot_leader,,non,oui, +https://steamusercontent-a.akamaihd.net/ugc/2491137781649821736/A6557A52AB579A320023B7028C55A38815DC566E/,secondaire,telecharge,riot_stormtrooper_mat,BucketheadBits/Units/Color Replacer,oui,oui, +https://steamusercontent-a.akamaihd.net/ugc/2491137781649838294/7197479F9D7ADF9DB1287E740A469A0E5C28F2ED/,principal,telecharge,stormtroopermarksman,,non,oui, diff --git a/mac-patcher/tools/inventory_mod_bundles.py b/mac-patcher/tools/inventory_mod_bundles.py new file mode 100644 index 000000000..460750ede --- /dev/null +++ b/mac-patcher/tools/inventory_mod_bundles.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Inventorie les bundles reellement references par le mod et dit lesquels ont besoin du fix Metal. + +Le mod ne liste pas ses assets dans sa save : les figurines sont spawnees +dynamiquement depuis mod/src/includes/generated/cards.ttslua, ou chaque mini +porte un `bundle` (maillage) et parfois un `secondary` (materiaux partages, donc +la teinte et le shader Color Replacer). Cf memoire registre-valide-invalide V17. + +Pour chaque URL : on prend le fichier dans le cache TTS local s'il y est, sinon +on le telecharge. On lit ensuite son nom d'AssetBundle interne, ses shaders +embarques, et on regarde si un rebuild du meme nom existe. + +Sortie : un CSV inventaire-bundles.csv exploitable pour la suite. +""" + +import csv +import glob +import hashlib +import os +import re +import sys +import time +import urllib.request + +import UnityPy + +REPO = "swlegion-tts/mod/src/includes/generated/cards.ttslua" +CACHE = os.path.expanduser("~/Library/Tabletop Simulator/Mods/Assetbundles") +DOWNLOADS = "tts-assets/published-bundles" +REBUILD_WIN = "UnityProject-U6/AssetBundles-win" +OUT = "inventaire-bundles.csv" + + +def urls_from_mod(): + """Renvoie {url: role} ou role vaut 'principal' ou 'secondaire'.""" + raw = open(REPO, encoding="utf-8").read() + out = {} + for u in re.findall(r'bundle\s*=\s*"([^"]+)"', raw): + out.setdefault(u, "principal") + for u in re.findall(r'secondary\s*=\s*"([^"]+)"', raw): + out[u] = "secondaire" # prime sur principal : c'est le role qui porte les materiaux + return out + + +def cache_index(): + """{identifiant ugc: chemin} du cache TTS local. + + On n'apparie PAS sur l'URL aplatie : le mod reference `cloud-3.steamusercontent.com` + alors que TTS met en cache sous le nom de l'URL Akamai. Seul l'identifiant + numerique `ugc/` est commun aux deux. + """ + index = {} + for path in glob.glob(CACHE + "/*.unity3d"): + for ugc_id in re.findall(r"ugc(\d{6,})", os.path.basename(path)): + index[ugc_id] = path + return index + + +def downloadable(url): + """`cloud-3.steamusercontent.com` repond 403 : l'hote Akamai en https sert le meme contenu.""" + url = re.sub(r"^https?://[^/]+", "https://steamusercontent-a.akamaihd.net", url) + return url + + +def local_copy(url, index): + """Chemin local du bundle : cache TTS si present, sinon telechargement. Renvoie (chemin, origine).""" + match = re.search(r"/ugc/(\d+)", url) + if match and match.group(1) in index: + return index[match.group(1)], "cache" + + dest = os.path.join(DOWNLOADS, hashlib.sha1(url.encode()).hexdigest()[:16] + ".unity3d") + if os.path.exists(dest): + return dest, "telecharge" + + os.makedirs(DOWNLOADS, exist_ok=True) + req = urllib.request.Request(downloadable(url), headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=180) as resp: + data = resp.read() + if not data.startswith(b"UnityFS"): + raise ValueError(f"reponse non-bundle ({len(data)} o)") + with open(dest, "wb") as fh: + fh.write(data) + return dest, "telecharge" + + +def inspect(path): + """(nom_du_bundle, [shaders embarques])""" + env = UnityPy.load(path) + name, shaders = None, [] + for obj in env.objects: + if obj.type.name == "AssetBundle" and name is None: + try: + name = obj.read_typetree().get("m_Name") + except Exception: + pass + elif obj.type.name == "Shader": + try: + shaders.append(obj.read_typetree()["m_ParsedForm"]["m_Name"]) + except Exception: + shaders.append("?") + return name, shaders + + +def main(): + urls = urls_from_mod() + rebuilt = { + os.path.basename(f) + for f in glob.glob(REBUILD_WIN + "/*") + if not f.endswith(".manifest") and os.path.basename(f) != "AssetBundles" + } + print(f"{len(urls)} URL de bundles referencees par le mod, {len(rebuilt)} bundles rebuildes disponibles") + + index = cache_index() + print(f"{len(index)} bundles dans le cache TTS local, indexes par identifiant ugc") + + rows = [] + started = time.time() + for i, (url, role) in enumerate(sorted(urls.items()), 1): + try: + path, origin = local_copy(url, index) + except Exception as exc: + rows.append({"url": url, "role": role, "origine": "ECHEC", "bundle": "", + "shaders": "", "besoin_fix": "", "rebuild_dispo": "", "note": str(exc)[:80]}) + print(f" ✗ telechargement {url} — {exc}") + continue + + try: + name, shaders = inspect(path) + except Exception as exc: + rows.append({"url": url, "role": role, "origine": origin, "bundle": "", + "shaders": "", "besoin_fix": "", "rebuild_dispo": "", "note": f"illisible: {exc}"[:80]}) + continue + + rows.append({ + "url": url, + "role": role, + "origine": origin, + "bundle": name or "", + "shaders": " | ".join(sorted(set(shaders))), + "besoin_fix": "oui" if shaders else "non", + "rebuild_dispo": "oui" if name in rebuilt else "non", + "note": "", + }) + + if i % 20 == 0 or i == len(urls): + print(f" {i}/{len(urls)} ({time.time() - started:.0f} s)") + + with open(OUT, "w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + + besoin = [r for r in rows if r["besoin_fix"] == "oui"] + pret = [r for r in besoin if r["rebuild_dispo"] == "oui"] + print(f"\n{len(rows)} bundles inventories") + print(f" ont besoin du fix (shader embarque) : {len(besoin)}") + print(f" dont un rebuild existe deja : {len(pret)}") + print(f" SANS rebuild (absents du projet) : {len(besoin) - len(pret)}") + for r in besoin: + if r["rebuild_dispo"] == "non": + print(f" ⚠ {r['bundle'] or ''} ({r['role']}) — {r['shaders'][:60]}") + print(f" ecrit {OUT}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mac-patcher/tools/merge_subshader_platforms.py b/mac-patcher/tools/merge_subshader_platforms.py new file mode 100644 index 000000000..f12204ef1 --- /dev/null +++ b/mac-patcher/tools/merge_subshader_platforms.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Fusionne deux bundles en donnant a chaque plateforme SON PROPRE SubShader. + +Pourquoi pas merge_shader_platforms.py : les tables de liaison de parametres +(m_NameIndices, m_CommonParameters, m_ConstantBufferBindings) sont stockees UNE +FOIS PAR PASSE et partagees par toutes les plateformes. DirectX et OpenGLCore +peuvent cohabiter parce qu'ils partagent cette disposition ($Globals, memes +offsets) — c'est ce que font les bundles publies du mod. Metal, lui, eclate les +globals en VGlobals/FGlobals et remet les offsets a zero : greffer un programme +Metal dans une passe DirectX lui donne les mauvaises liaisons, et il rend faux +sans aucun message d'erreur (verifie le 12/08/2026). + +Un shader peut en revanche declarer plusieurs SubShaders, et chacun porte ses +propres passes donc ses propres tables. On empile donc le SubShader du bundle +macOS derriere celui du bundle Windows, en esperant qu'Unity descende au suivant +quand le premier n'a pas de variante pour l'API courante. + +Usage: + merge_subshader_platforms.py +""" + +import sys + +import UnityPy + +GPU = {4: "d3d11", 9: "gles3", 14: "metal", 15: "glcore", 18: "vulkan"} + + +def shaders_by_name(env): + out = {} + for obj in env.objects: + if obj.type.name != "Shader": + continue + tree = obj.read_typetree() + out[tree["m_ParsedForm"]["m_Name"]] = tree + return out + + +def merge(base_tree, donor_tree): + """Concatene les blobs, puis ajoute les SubShaders du donneur. Renvoie le nb de plateformes ajoutees.""" + added = 0 + blob = bytes(base_tree["compressedBlob"]) + + for i, platform in enumerate(donor_tree["platforms"]): + if platform in base_tree["platforms"]: + continue + shift = len(blob) + base_tree["platforms"].append(platform) + base_tree["offsets"].append([o + shift for o in donor_tree["offsets"][i]]) + base_tree["compressedLengths"].append(list(donor_tree["compressedLengths"][i])) + base_tree["decompressedLengths"].append(list(donor_tree["decompressedLengths"][i])) + base_tree["stageCounts"].append(donor_tree["stageCounts"][i]) + blob += bytes(donor_tree["compressedBlob"]) + added += 1 + + if added: + base_tree["compressedBlob"] = list(blob) + # Le SubShader du donneur arrive AVEC ses tables de parametres : c'est + # tout l'interet de la manoeuvre. + base_tree["m_ParsedForm"]["m_SubShaders"].extend(donor_tree["m_ParsedForm"]["m_SubShaders"]) + + return added + + +def main(base_path, donor_path, out_path): + base_env = UnityPy.load(base_path) + donors = shaders_by_name(UnityPy.load(donor_path)) + total = 0 + + for obj in base_env.objects: + if obj.type.name != "Shader": + continue + tree = obj.read_typetree() + name = tree["m_ParsedForm"]["m_Name"] + if name not in donors: + print(f" {name}: absent du bundle d'apport, laisse tel quel") + continue + + before = [GPU.get(p, p) for p in tree["platforms"]] + nsub_before = len(tree["m_ParsedForm"]["m_SubShaders"]) + added = merge(tree, donors[name]) + after = [GPU.get(p, p) for p in tree["platforms"]] + nsub = len(tree["m_ParsedForm"]["m_SubShaders"]) + + if added: + obj.save_typetree(tree) + total += added + print(f" {name}: {before} -> {after}, SubShaders {nsub_before} -> {nsub}") + + if not total: + print(" rien a fusionner") + + with open(out_path, "wb") as fh: + fh.write(base_env.file.save(packer="lzma")) + print(f" ecrit {out_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print(__doc__) + sys.exit(1) + main(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/mac-patcher/tools/patch_shader_vertexcolor.py b/mac-patcher/tools/patch_shader_vertexcolor.py new file mode 100644 index 000000000..32c6c8aab --- /dev/null +++ b/mac-patcher/tools/patch_shader_vertexcolor.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Neutralise la lecture de la couleur de sommet, le temps d'un build macOS. + +Pourquoi : les shaders d'effets lumineux lisent `v.color`, un canal de sommet +que AUCUN mesh du mod ne possede. Quand le canal manque, DirectX et OpenGL +fournissent du blanc (1,1,1,1) et l'effet s'allume ; Metal fournit du noir +(0,0,0,0). Les passes concernees sont en `Blend One One`, donc multipliees par +zero elles n'ajoutent rien : la lame de Yoda rend, mais ne brille pas. Constate +en jeu le 12/08/2026, puis demontre. + +Dans GlowGeometry.shader l'extinction est double : + + o.v.x = min(1.0f, viewSat * v.color.a); // le fonduanguleux -> 0 + float4 lerpColor = _FarColor * pow(...) * i.col.r * _Contrast; // l'intensite -> 0 + +On remplace donc `v.color` par du blanc, ce qui reproduit exactement ce que +Windows recoit. C'est sur : sur les 408 meshes des 248 bundles publies, UN SEUL +porte un canal Color (`del_meeko`), et il n'utilise aucun shader custom — il +fait partie des bundles qu'on ne touche jamais. + +Comme on ne greffe QUE le SubShader macOS, le rendu Windows n'est pas altere. + +⚠ Se combine avec patch_shader_precision.py : les deux scripts sauvegardent en +`.orig` sans jamais ecraser une sauvegarde existante, donc appliquer les deux a +la suite conserve le vrai original, et le `--restore` de l'un remet tout en +place. + +Usage : + patch_shader_vertexcolor.py --apply # patche les .shader (sauvegarde en .orig) + patch_shader_vertexcolor.py --restore # remet les sources d'origine +""" + +import argparse +import os +import re +import shutil +import sys + +ROOT = "UnityProject-U6/Assets" + +# Liste explicite plutot qu'un balayage : le remplacement n'est legitime que +# pour NOS shaders, sur des meshes dont on a verifie l'absence du canal. Les +# shaders tiers (SineVFX) travaillent sur leurs propres geometries. +TARGETS = [ + "Units/_Shaders/GlowGeometry.shader", + "Units/_Shaders/RimGlowGeometry.shader", + "Units/_Shaders/RimGlowGeometry_Shield.shader", + "Units/_Shaders/BlasterBolt_1Pass.shader", + "Shaders/BB_Sprite_BillboardY.shader", +] + +VERTEX_COLOR = re.compile(r"\bv\.color\b") +WHITE = "float4(1,1,1,1)" + + +def apply(): + touched = 0 + for rel in TARGETS: + path = os.path.join(ROOT, rel) + if not os.path.exists(path): + print(f" ⚠ absent, ignore : {rel}") + continue + + # Certains shaders sont en latin-1 (le "© Allen White" de l'en-tete) : + # lire en binaire et ne pas reencoder, pour ne rien alterer d'autre. + with open(path, "rb") as fh: + raw = fh.read() + encoding = "utf-8-sig" + try: + src = raw.decode(encoding) + except UnicodeDecodeError: + encoding = "latin-1" + src = raw.decode(encoding) + + out, n = VERTEX_COLOR.subn(WHITE, src) + if not n: + print(f" ⚠ aucune occurrence de v.color dans {rel}") + continue + + backup = path + ".orig" + if not os.path.exists(backup): # ne jamais ecraser une sauvegarde par du deja patche + shutil.copy2(path, backup) + with open(path, "w", encoding=encoding) as fh: + fh.write(out) + touched += 1 + print(f" patche {rel} ({n} occurrence{'s' if n > 1 else ''})") + + print(f"{touched} shaders sur {len(TARGETS)} neutralises") + return 0 if touched == len(TARGETS) else 1 + + +def restore(): + count = 0 + for base, dirs, files in os.walk(ROOT): + for name in files: + if not name.endswith(".orig"): + continue + backup = os.path.join(base, name) + shutil.move(backup, backup[:-5]) + count += 1 + print(f"{count} shaders restaures") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--apply", action="store_true") + group.add_argument("--restore", action="store_true") + args = parser.parse_args() + return apply() if args.apply else restore() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mac-support-package/BuildBiPlatformBundle.cs b/mac-support-package/BuildBiPlatformBundle.cs new file mode 100644 index 000000000..a250dbfb9 --- /dev/null +++ b/mac-support-package/BuildBiPlatformBundle.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +// Builds the selected prefab(s) as AssetBundles for BOTH Windows and macOS, so +// the two can be merged into a single file that renders on either platform. +// +// Put this in any Editor/ folder of the Unity project, then either: +// - select prefabs in the Project window and use Assets > Build Bundle (Windows + macOS) +// - or run it in batch mode: +// Unity -batchmode -quit -projectPath . \ +// -executeMethod BuildBiPlatformBundle.Run -logFile build.log +// (batch mode builds every asset that already has an AssetBundle name) +// +// Output: BiPlatformBundles/win/ and BiPlatformBundles/mac/. +// Then merge each pair: +// python3 merge_subshader_bundles.py BiPlatformBundles/win/ \ +// BiPlatformBundles/mac/ +// and upload the merged file. Windows players get the exact same rendering as +// before, Mac players stop seeing magenta and raw team-colour masks. +public static class BuildBiPlatformBundle +{ + const string OutRoot = "BiPlatformBundles"; + + [MenuItem("Assets/Build Bundle (Windows + macOS)")] + static void BuildSelection() + { + var builds = new System.Collections.Generic.List(); + + foreach (var obj in Selection.objects) + { + string path = AssetDatabase.GetAssetPath(obj); + if (string.IsNullOrEmpty(path)) continue; + + // Reuse the bundle name already set on the asset when there is one, + // so the output matches what the mod expects. + var importer = AssetImporter.GetAtPath(path); + string name = importer != null && !string.IsNullOrEmpty(importer.assetBundleName) + ? importer.assetBundleName + : Path.GetFileNameWithoutExtension(path).ToLowerInvariant(); + + builds.Add(new AssetBundleBuild { assetBundleName = name, assetNames = new[] { path } }); + } + + if (builds.Count == 0) + { + Debug.LogError("Select at least one prefab in the Project window."); + return; + } + + BuildBoth(builds.ToArray()); + } + + [MenuItem("Assets/Build Bundle (Windows + macOS)", true)] + static bool BuildSelectionValidate() + { + return Selection.objects != null && Selection.objects.Length > 0; + } + + public static void Run() + { + // Batch mode: build everything that already carries an AssetBundle name. + BuildBoth(null); + } + + static void BuildBoth(AssetBundleBuild[] builds) + { + BuildOne(builds, BuildTarget.StandaloneWindows64, Path.Combine(OutRoot, "win")); + BuildOne(builds, BuildTarget.StandaloneOSX, Path.Combine(OutRoot, "mac")); + Debug.Log("BIPLATFORM: done. Now merge each pair with merge_subshader_bundles.py"); + } + + static void BuildOne(AssetBundleBuild[] builds, BuildTarget target, string outputPath) + { + Directory.CreateDirectory(outputPath); + var started = DateTime.Now; + + AssetBundleManifest manifest = builds == null + ? BuildPipeline.BuildAssetBundles(outputPath, BuildAssetBundleOptions.None, target) + : BuildPipeline.BuildAssetBundles(outputPath, builds, BuildAssetBundleOptions.None, target); + + if (manifest == null) + { + Debug.LogError("BIPLATFORM: build failed for " + target); + return; + } + + Debug.Log(string.Format("BIPLATFORM: {0} -> {1} ({2} bundles, {3:F1} min)", + target, outputPath, manifest.GetAllAssetBundles().Length, + (DateTime.Now - started).TotalMinutes)); + } +} diff --git a/mac-support-package/README.txt b/mac-support-package/README.txt new file mode 100644 index 000000000..57f671f62 --- /dev/null +++ b/mac-support-package/README.txt @@ -0,0 +1,135 @@ +Making the mod's AssetBundles work on Mac +========================================= + +Short version: build each bundle twice and merge the two files. One extra step +for whoever builds the bundles, and every mini from then on renders correctly on +both platforms. Nothing changes for Windows players. + + +WHAT IS BROKEN AND WHY + +Shader variants inside an AssetBundle are compiled per graphics API, decided by +the build target. The mod's bundles were built for Windows, so they carry d3d11 +and glcore variants (we scanned them: 60 of 62 shaders are exactly that pair). +That was correct at the time, and it is why the mod used to work on Mac: TTS ran +OpenGL there. + +Since TTS v14 the Mac player runs Metal and has no usable OpenGL path left. +-force-glcore reaches the process and is silently ignored. So the custom shaders +never find a variant they can load. TTS substitutes the Standard shader where it +can, which is why minis show their raw team-colour mask, and renders magenta +where it cannot, which is why the projectors look worst. + +Nothing is wrong with the shader code. It was simply never compiled for Metal. + + +THE FIX, IN THREE PARTS + +1. Two shader-source changes, worth doing once and for all +---------------------------------------------------------- + +In ColorReplacer.shader (and the other BucketheadBits shaders), the team colour +swap is computed in fixed precision: + + fixed ramp = 1.0 - distance(c.rgb, _SwapColor.rgb); + fixed swapMask = saturate((ramp - _SwapCutoff) * _SwapContrast); + +On DirectX, fixed and half are treated as 32-bit floats. On Metal, half is a real +16-bit float. The mask is a near-binary threshold amplified by _SwapContrast (5.0 +by default), so in 16 bits the pixels sitting near the threshold flip at random: +speckled bases, cape linings left green, magenta fringes along the swapped areas. + +Changing fixed and half to float in these shaders is a no-op on Windows (they +were already 32-bit there) and fixes Metal for good. That is the cleanest place +to fix it: once, in the source. + +The second change matters most for whatever you build next, because it hits any +mini with a glow effect. The BucketheadBits glow shaders multiply their effect by +the vertex colour: + + o.v.x = min(1.0f, viewSat * v.color.a); + float4 lerpColor = _FarColor * pow(...) * i.col.r * _Contrast; + +No mesh in the mod carries a Color channel. When the channel is missing, DirectX +and OpenGL hand the shader white (1,1,1,1) and the effect lights up; Metal hands +it black (0,0,0,0). The passes are Blend One One, so multiplied by zero they add +exactly nothing. The mesh renders, the glow does not, and nothing is logged. + +This one is nasty precisely because it is invisible: it does not go magenta, it +does not error, the model just looks flat on Mac. It had switched off 12 bundles +worth of lightsabers, IG-88's eyes and the Droideka's shield without anyone +noticing. Replacing v.color with white in these shaders restores exactly what +Windows already receives, and is a no-op there. We checked every mesh in the +published bundles first: exactly one carries a Color channel, and it uses no +custom shader. + +2. Build each bundle twice, then merge +-------------------------------------- + +Unity cannot produce a single bundle covering both platforms: ask for Metal in a +Windows build and it silently drops it. But a Shader object can declare several +SubShaders, and Unity falls through to the next one when the first has no variant +for the current graphics API. + +So: build the prefab for StandaloneWindows64, build it again for StandaloneOSX, +and stack the macOS SubShader behind the Windows one in the same file. + + # 1. build both (Editor menu: Assets > Build Bundle (Windows + macOS)) + # -> BiPlatformBundles/win/ and BiPlatformBundles/mac/ + + # 2. merge + pip install UnityPy + python3 merge_subshader_bundles.py BiPlatformBundles/win/ \ + BiPlatformBundles/mac/ + + # 3. upload as usual + +The Windows SubShader stays first and the merge does not touch it, so Windows +players get exactly what they get today. The extra SubShader is inert for them. + +3. When the sources are gone, graft instead of rebuilding +--------------------------------------------------------- + +Some of the older units have no usable sources any more, and one had a rebuild +that no longer matched the object you publish. You do not need the sources: a +Metal SubShader can be grafted straight into the published bundle. + +The one thing that matters is building the graft in the same Unity version the +bundle was made with. These were made with 2019.1.9f1, not the 2019.4 we first +assumed, and a graft from the wrong version loads without complaint and renders +wrong. Nine bundles took this route, with no model rebuilt and nothing else about +them touched. + + +FILES HERE + + BuildBiPlatformBundle.cs drop into any Editor/ folder. Adds + Assets > Build Bundle (Windows + macOS), and works + in batch mode via + -executeMethod BuildBiPlatformBundle.Run + merge_subshader_bundles.py the merge step. Requires UnityPy. + + +ONE TRAP, IF YOU GO DIGGING + +Merging the variants inside a single pass does not work. The parameter binding +tables (m_NameIndices, m_CommonParameters, m_ConstantBufferBindings) are stored +once per pass and shared by every platform in it, and Metal lays them out +differently from DirectX (VGlobals/FGlobals instead of $Globals, offsets reset to +zero). The result loads, logs nothing, and renders wrong, which costs a lot of +time to diagnose. A SubShader carries its own tables. It is also why d3d11 and +glcore coexist happily in the current bundles: those two share the layout. + + +STATUS ON OUR SIDE + +Tested in game on both platforms with the same merged file: an A-A5 Speeder Truck +renders correctly on Mac and on Windows. + +Of the 266 bundles the mod references, 114 use no custom shader and were never +broken. The other 152 are all repaired: 143 rebuilt from sources and merged, 9 +grafted. Nothing is left broken on Mac. They are running here, and they are +downloadable from the release linked in PR #600 if you want to try them before +deciding anything. + +Happy to help wire this into your build, or to hand over the repaired bundles. diff --git a/mac-support-package/merge_subshader_bundles.py b/mac-support-package/merge_subshader_bundles.py new file mode 100644 index 000000000..382bc7cc6 --- /dev/null +++ b/mac-support-package/merge_subshader_bundles.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Merge a Windows and a macOS AssetBundle into one file that renders on both. + +Why this exists +--------------- +AssetBundle shader variants are compiled per graphics API, decided by the build +target. A StandaloneWindows64 build carries DirectX (and OpenGL) variants only; a +StandaloneOSX build carries Metal only. Since TTS v14 the Mac player runs Metal +and has no usable OpenGL path left, so bundles built for Windows cannot load +their custom shaders there: TTS falls back to Standard where it can, and renders +magenta where it cannot. + +Unity cannot produce one bundle covering both platforms: it silently drops any +graphics API that is foreign to the build target. But a Shader object can declare +several SubShaders, and Unity falls through to the next one when the first has no +variant for the current graphics API. So we build twice and stack the macOS +SubShader behind the Windows one. + +The Windows SubShader stays first and byte-identical, so Windows players get +exactly what they get today. The extra SubShader is inert for them. + +⚠ Do NOT try to merge the variants inside a single pass. The parameter binding +tables (m_NameIndices, m_CommonParameters, m_ConstantBufferBindings) are stored +once per pass and shared by every platform in it, and Metal lays them out +differently from DirectX (VGlobals/FGlobals instead of $Globals, offsets reset to +zero). The result loads, logs nothing, and renders wrong. A SubShader carries its +own tables, which is why this works. It is also why d3d11 and glcore coexist +happily in the current bundles: those two share the layout. + +Requirements: pip install UnityPy + +Usage: + merge_subshader_bundles.py + +Both bundles must come from the same prefab, built twice with only the target +changed. Shaders are matched by name. +""" + +import sys + +import UnityPy + +GPU = {4: "d3d11", 9: "gles3", 14: "metal", 15: "glcore", 18: "vulkan"} + + +def shaders_by_name(env): + out = {} + for obj in env.objects: + if obj.type.name != "Shader": + continue + tree = obj.read_typetree() + out[tree["m_ParsedForm"]["m_Name"]] = tree + return out + + +def merge(base_tree, donor_tree): + """Concatenate the blobs, then append the donor's SubShaders. Returns platforms added.""" + added = 0 + blob = bytes(base_tree["compressedBlob"]) + + for i, platform in enumerate(donor_tree["platforms"]): + if platform in base_tree["platforms"]: + continue + # The donor's offsets are relative to its own blob, so rebase them. + shift = len(blob) + base_tree["platforms"].append(platform) + base_tree["offsets"].append([o + shift for o in donor_tree["offsets"][i]]) + base_tree["compressedLengths"].append(list(donor_tree["compressedLengths"][i])) + base_tree["decompressedLengths"].append(list(donor_tree["decompressedLengths"][i])) + base_tree["stageCounts"].append(donor_tree["stageCounts"][i]) + blob += bytes(donor_tree["compressedBlob"]) + added += 1 + + if added: + base_tree["compressedBlob"] = list(blob) + # The donor's SubShader arrives with its own parameter tables. That is the + # whole point of doing it this way. + base_tree["m_ParsedForm"]["m_SubShaders"].extend(donor_tree["m_ParsedForm"]["m_SubShaders"]) + + return added + + +def main(base_path, donor_path, out_path): + base_env = UnityPy.load(base_path) + donors = shaders_by_name(UnityPy.load(donor_path)) + total = 0 + + for obj in base_env.objects: + if obj.type.name != "Shader": + continue + tree = obj.read_typetree() + name = tree["m_ParsedForm"]["m_Name"] + if name not in donors: + print(f" {name}: not in the macOS bundle, left as is") + continue + + before = [GPU.get(p, p) for p in tree["platforms"]] + subshaders_before = len(tree["m_ParsedForm"]["m_SubShaders"]) + added = merge(tree, donors[name]) + after = [GPU.get(p, p) for p in tree["platforms"]] + + if added: + obj.save_typetree(tree) + total += added + print(f" {name}: {before} -> {after}, " + f"SubShaders {subshaders_before} -> {len(tree['m_ParsedForm']['m_SubShaders'])}") + + if not total: + print(" nothing to merge (no shared shader between the two bundles)") + + with open(out_path, "wb") as fh: + fh.write(base_env.file.save(packer="lzma")) + print(f" wrote {out_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print(__doc__) + sys.exit(1) + main(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/mod/data/mac-fallback-assets/cohesion_halo.png b/mod/data/mac-fallback-assets/cohesion_halo.png deleted file mode 100644 index 4b3bb8353..000000000 Binary files a/mod/data/mac-fallback-assets/cohesion_halo.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/deployment_blue.png b/mod/data/mac-fallback-assets/deployment_blue.png deleted file mode 100644 index 093494143..000000000 Binary files a/mod/data/mac-fallback-assets/deployment_blue.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/deployment_red.png b/mod/data/mac-fallback-assets/deployment_red.png deleted file mode 100644 index a8e05c5e2..000000000 Binary files a/mod/data/mac-fallback-assets/deployment_red.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/halfcohesion_100mm_isq_v2.unity3d b/mod/data/mac-fallback-assets/halfcohesion_100mm_isq_v2.unity3d new file mode 100644 index 000000000..a0b5ed538 Binary files /dev/null and b/mod/data/mac-fallback-assets/halfcohesion_100mm_isq_v2.unity3d differ diff --git a/mod/data/mac-fallback-assets/halfcohesion_120mm_isq_v2.unity3d b/mod/data/mac-fallback-assets/halfcohesion_120mm_isq_v2.unity3d new file mode 100644 index 000000000..f049227b0 Binary files /dev/null and b/mod/data/mac-fallback-assets/halfcohesion_120mm_isq_v2.unity3d differ diff --git a/mod/data/mac-fallback-assets/halfcohesion_150mm_isq_v2.unity3d b/mod/data/mac-fallback-assets/halfcohesion_150mm_isq_v2.unity3d new file mode 100644 index 000000000..f9b7a4cde Binary files /dev/null and b/mod/data/mac-fallback-assets/halfcohesion_150mm_isq_v2.unity3d differ diff --git a/mod/data/mac-fallback-assets/halfcohesion_long_isq_v3.unity3d b/mod/data/mac-fallback-assets/halfcohesion_long_isq_v3.unity3d new file mode 100644 index 000000000..53b43c1c5 Binary files /dev/null and b/mod/data/mac-fallback-assets/halfcohesion_long_isq_v3.unity3d differ diff --git a/mod/data/mac-fallback-assets/halfcohesion_snail_isq_v3.unity3d b/mod/data/mac-fallback-assets/halfcohesion_snail_isq_v3.unity3d new file mode 100644 index 000000000..efd671014 Binary files /dev/null and b/mod/data/mac-fallback-assets/halfcohesion_snail_isq_v3.unity3d differ diff --git a/mod/data/mac-fallback-assets/iron_squadron_logo_v2.png b/mod/data/mac-fallback-assets/iron_squadron_logo_v2.png new file mode 100644 index 000000000..268e398b0 Binary files /dev/null and b/mod/data/mac-fallback-assets/iron_squadron_logo_v2.png differ diff --git a/mod/data/mac-fallback-assets/max_move_cyan.png b/mod/data/mac-fallback-assets/max_move_cyan.png deleted file mode 100644 index dc017745a..000000000 Binary files a/mod/data/mac-fallback-assets/max_move_cyan.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/projector_100mm_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_100mm_isq_v6.unity3d new file mode 100644 index 000000000..c4ce924cd Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_100mm_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_100mm_oblong_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_100mm_oblong_isq_v6.unity3d new file mode 100644 index 000000000..1082decfc Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_100mm_oblong_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_120mm_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_120mm_isq_v6.unity3d new file mode 100644 index 000000000..a9c14dc7d Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_120mm_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_150mm_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_150mm_isq_v6.unity3d new file mode 100644 index 000000000..ac06440e3 Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_150mm_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_200mm_oblong_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_200mm_oblong_isq_v6.unity3d new file mode 100644 index 000000000..ead2bedc3 Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_200mm_oblong_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_27mm_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_27mm_isq_v6.unity3d new file mode 100644 index 000000000..1404e46a7 Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_27mm_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_50mm_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_50mm_isq_v6.unity3d new file mode 100644 index 000000000..9c221da85 Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_50mm_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/projector_70mm_isq_v6.unity3d b/mod/data/mac-fallback-assets/projector_70mm_isq_v6.unity3d new file mode 100644 index 000000000..53ac7eaf6 Binary files /dev/null and b/mod/data/mac-fallback-assets/projector_70mm_isq_v6.unity3d differ diff --git a/mod/data/mac-fallback-assets/range_bombCart.png b/mod/data/mac-fallback-assets/range_bombCart.png deleted file mode 100644 index 766ac5872..000000000 Binary files a/mod/data/mac-fallback-assets/range_bombCart.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_epic.png b/mod/data/mac-fallback-assets/range_fig_leader_epic.png deleted file mode 100644 index 4a1fcec48..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_epic.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_huge.png b/mod/data/mac-fallback-assets/range_fig_leader_huge.png deleted file mode 100644 index 6ad39ebcb..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_huge.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_laat.png b/mod/data/mac-fallback-assets/range_fig_leader_laat.png deleted file mode 100644 index 3a2ac1d22..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_laat.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_large.png b/mod/data/mac-fallback-assets/range_fig_leader_large.png deleted file mode 100644 index c1baafb4c..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_large.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_long_stadium.png b/mod/data/mac-fallback-assets/range_fig_leader_long_stadium.png deleted file mode 100644 index f8c2a4e8d..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_long_stadium.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_medium.png b/mod/data/mac-fallback-assets/range_fig_leader_medium.png deleted file mode 100644 index fdd35e6ce..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_medium.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_small.png b/mod/data/mac-fallback-assets/range_fig_leader_small.png deleted file mode 100644 index ec74193af..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_small.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_fig_leader_snail_stadium.png b/mod/data/mac-fallback-assets/range_fig_leader_snail_stadium.png deleted file mode 100644 index 81c68a68c..000000000 Binary files a/mod/data/mac-fallback-assets/range_fig_leader_snail_stadium.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_poi.png b/mod/data/mac-fallback-assets/range_poi.png deleted file mode 100644 index 5a282adb3..000000000 Binary files a/mod/data/mac-fallback-assets/range_poi.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_smokeToken.png b/mod/data/mac-fallback-assets/range_smokeToken.png deleted file mode 100644 index 5a282adb3..000000000 Binary files a/mod/data/mac-fallback-assets/range_smokeToken.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_token.png b/mod/data/mac-fallback-assets/range_token.png deleted file mode 100644 index 5a282adb3..000000000 Binary files a/mod/data/mac-fallback-assets/range_token.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/range_tokenRangeTwo.png b/mod/data/mac-fallback-assets/range_tokenRangeTwo.png deleted file mode 100644 index 4a8a2f488..000000000 Binary files a/mod/data/mac-fallback-assets/range_tokenRangeTwo.png and /dev/null differ diff --git a/mod/data/mac-fallback-assets/silhouette_mac_fallback.unity3d b/mod/data/mac-fallback-assets/silhouette_mac_fallback.unity3d deleted file mode 100644 index 15bb9258a..000000000 Binary files a/mod/data/mac-fallback-assets/silhouette_mac_fallback.unity3d and /dev/null differ diff --git a/mod/data/mac-fallback-assets/token05_27mm_isq_v1.unity3d b/mod/data/mac-fallback-assets/token05_27mm_isq_v1.unity3d new file mode 100644 index 000000000..427f27265 Binary files /dev/null and b/mod/data/mac-fallback-assets/token05_27mm_isq_v1.unity3d differ diff --git a/mod/src/StarWarsLegion.lua b/mod/src/StarWarsLegion.lua index 989babcf6..2ddaa6966 100644 --- a/mod/src/StarWarsLegion.lua +++ b/mod/src/StarWarsLegion.lua @@ -4,7 +4,6 @@ require('!/common/SHA256') require('!/data/ListBuilder') require('!/generated/cards') require('!/UI') -require('!/Overlays') require('!/RangeRulers') require('!/Cohesion') diff --git a/mod/src/StarWarsLegion/Order_Token.a57c41.lua b/mod/src/StarWarsLegion/Order_Token.a57c41.lua index 17eee6fde..fbf2ff744 100644 --- a/mod/src/StarWarsLegion/Order_Token.a57c41.lua +++ b/mod/src/StarWarsLegion/Order_Token.a57c41.lua @@ -366,20 +366,15 @@ function resetButtons() end end -function toggleCohesionRuler(_, playerColor) - -- Mac fallback: capture playerColor (TTS passes it as 2nd arg on click) - -- and route through gCohesionTrigger so the per-seat router picks the - -- right renderer for the clicking player. +function toggleCohesionRuler() if not rulerOn then - Global.call("gCohesionTrigger", { - figGUID = selectedUnitObj.getGUID(), - playerColor = playerColor, - }) + selectedUnitObj.call("spawnCohesionRuler", selectedUnitObj) rulerOn = true else selectedUnitObj.call("clearCohesionRuler") rulerOn = false end + end ------------------------------------------------- NEXTUNIT ------------------------------------------------------------ @@ -556,37 +551,26 @@ function moveUnit(isDeploy) local maxMoveBundles = getMovementLinks() local baseSizeMoveBundles = maxMoveBundles[unitData.baseSize] - local maxMoveTemplateBundleToSpawn = baseSizeMoveBundles and baseSizeMoveBundles[unitData.selectedSpeed] + local maxMoveTemplateBundleToSpawn = baseSizeMoveBundles[unitData.selectedSpeed] - -- Mac fallback: permissive condition (covers nil + false) so the overlay - -- also fires when changeSpeed2/3 calls moveUnit() with no isDeploy arg. - if isDeploy ~= true then + if isDeploy == false then + --max movement ring projector if maxMoveTemplateBundleToSpawn ~= nil then - local _macMode = Global.call("gGetMode", {color = macActivePlayerForMove}) - if _macMode == "windows" then - -- WINDOWS ORIGINAL: spawn Custom_AssetBundle Projector - maxMoveTemplate = spawnObject({ - type = "Custom_AssetBundle", - position = {basePos.x, basePos.y + 20, basePos.z}, - rotation = {0, basePos.y, 0}, - scale = {0,0,0} - }) - maxMoveTemplate.setCustomObject({ - type = 0, - assetbundle = maxMoveTemplateBundleToSpawn - }) - maxMoveTemplate.setLock(true) - maxMoveTemplate.use_gravity = false - maxMoveTemplate.setName("Maximum Move") - else - -- MAC FALLBACK: route through the Global Overlays manager - Global.call("gSpawnMaxMove", { - figGUID = selectedUnitObj.getGUID(), - baseSize = unitData.baseSize, - speed = unitData.selectedSpeed, - }) - maxMoveTemplate = nil - end + maxMoveTemplate = spawnObject({ + type = "Custom_AssetBundle", + position = {basePos.x, basePos.y + 20, basePos.z}, + rotation = {0, basePos.y, 0}, + scale = {0,0,0} -- 0 scale will hide TTS default box and won't impact projector + }) + + maxMoveTemplate.setCustomObject({ + type = 0, + assetbundle = maxMoveTemplateBundleToSpawn + }) + + maxMoveTemplate.setLock(true) + maxMoveTemplate.use_gravity = false + maxMoveTemplate.setName("Maximum Move") end end ------------------------------------------- SPAWN BUTTON ------------------------------------------- @@ -738,8 +722,7 @@ function moveStart() end) end -function moveBackwards(_, playerColor) - macActivePlayerForMove = playerColor -- Mac fallback per-seat capture +function moveBackwards() self.editButton({ index = 11, click_function = "moveForward", @@ -750,8 +733,7 @@ function moveBackwards(_, playerColor) moveUnit() end -function moveForward(_, playerColor) - macActivePlayerForMove = playerColor -- Mac fallback per-seat capture +function moveForward() self.editButton({ index = 11, click_function = "moveBackwards", @@ -762,14 +744,12 @@ function moveForward(_, playerColor) moveUnit() end -function moveLeft(_, playerColor) - macActivePlayerForMove = playerColor -- Mac fallback per-seat capture +function moveLeft() moveDirection = "left" moveUnit() end -function moveRight(_, playerColor) - macActivePlayerForMove = playerColor -- Mac fallback per-seat capture +function moveRight() moveDirection = "right" moveUnit() end @@ -800,14 +780,9 @@ function clearMovementTemplates() if templateB ~= nil then destroyObject(templateB) end - -- Mac fallback dual-path: destroy bundle if Windows-mode spawn produced - -- one, AND clear the Global Overlays manager entry (Mac fallback). Either - -- may be set depending on the active player's mode at spawn time. if maxMoveTemplate ~= nil then - pcall(destroyObject, maxMoveTemplate) + destroyObject(maxMoveTemplate) end - Global.call("gClearAllMaxMove", {}) - maxMoveTemplate = nil end function clearCohesionRulers() @@ -828,27 +803,21 @@ end ------------------------------------------------- CHANGESPEED------------------------------------------------------------ --- Mac fallback per-seat capture: each speed button stashes the clicking --- player's color so the dual-path branch in moveUnit() can pick the right --- renderer for the Maximum Move overlay. -function changeSpeed1(_, playerColor) - macActivePlayerForMove = playerColor +function changeSpeed1() unitData.selectedSpeed = 1 setTemplateVariables() clearTemplates() moveUnit() end -function changeSpeed2(_, playerColor) - macActivePlayerForMove = playerColor +function changeSpeed2() unitData.selectedSpeed = 2 setTemplateVariables() clearTemplates() moveUnit() end -function changeSpeed3(_, playerColor) - macActivePlayerForMove = playerColor +function changeSpeed3() unitData.selectedSpeed = 3 setTemplateVariables() clearTemplates() @@ -950,15 +919,11 @@ function attack() attackMode() end -function targetingMode(_, playerColor) - -- Mac fallback: capture playerColor and route through gRangeTrigger. +function targetingMode() if not enemyHighlighted then exitAttackMode() highlightEnemies() - Global.call("gRangeTrigger", { - figGUID = selectedUnitObj.getGUID(), - playerColor = playerColor, - }) + spawnRangeRuler(selectedUnitObj) enemyHighlighted = true resetRangeButtons() else diff --git a/mod/src/StarWarsLegion/SETUP_CONTROLLER.1cb552.lua b/mod/src/StarWarsLegion/SETUP_CONTROLLER.1cb552.lua index 399273af3..8c407c4c3 100644 --- a/mod/src/StarWarsLegion/SETUP_CONTROLLER.1cb552.lua +++ b/mod/src/StarWarsLegion/SETUP_CONTROLLER.1cb552.lua @@ -251,24 +251,17 @@ function spawnBoundaryCell(cell, x, z) pos = AddVectors(pos, offset) - -- Mac fallback dual-path: branch on the table-wide deploymentMode toggle. - local _macDeployMode = Global.call("gGetDeploymentMode") - local projector = nil - if _macDeployMode == "windows" then - projector = spawnObject({ - type = "Custom_AssetBundle", - position = pos, - scale = {0, 0, 0}, - rotation = {0, deployRotations[cell], 0} - }) - projector.setName("Deployment Boundary") - projector.setLock(true) - projector.setCustomObject({ - assetbundle = asset, - }) - else - Global.call("gSpawnDeployment", { cell = cell, pos = pos }) - end + local projector = spawnObject({ + type = "Custom_AssetBundle", + position = pos, + scale = {0, 0, 0}, + rotation = {0, deployRotations[cell], 0} + }) + projector.setName("Deployment Boundary") + projector.setLock(true) + projector.setCustomObject({ + assetbundle = asset, + }) end function spawnDeploymentBoundary(matrix) @@ -321,9 +314,6 @@ function spawnDeploymentBoundary(matrix) end function clearDeploymentBoundary() - -- Mac fallback: clear Global Overlays manager entries first (no-op if - -- deployment ran in Windows mode and no Mac entries exist). - Global.call("gClearAllDeployment", {}) local battlefieldObjs = battlefieldZone.getObjects() for _, obj in pairs(battlefieldObjs) do if obj.getName() == "Deployment Boundary" then diff --git a/mod/src/includes/Cohesion.ttslua b/mod/src/includes/Cohesion.ttslua index 38fadbe33..1a28778af 100644 --- a/mod/src/includes/Cohesion.ttslua +++ b/mod/src/includes/Cohesion.ttslua @@ -58,30 +58,4 @@ function clearCohesionRuler() cohesionRuler.destruct() cohesionRuler = nil end -end --- ============================================ --- Mac fallback per-seat router (appended). --- Alias the original spawn/clear so Windows-mode players still get their --- bundle Projector, while routing default calls through Global Overlays. --- See !/Overlays for the manager. The `*Original` aliases are called by --- gCohesionTrigger when the active player has chosen "windows" mode. --- ============================================ -spawnCohesionRulerOriginal = spawnCohesionRuler -clearCohesionRulerOriginal = clearCohesionRuler - -function spawnCohesionRuler(cohesionSourceObject) - if not cohesionSourceObject then return end - Global.call("gCohesionTrigger", { - figGUID = cohesionSourceObject.getGUID(), - playerColor = nil, - }) -end - -function clearCohesionRuler() - if self and self.getGUID and self.getGUID() ~= "-1" then - Global.call("gClearCohesion", { figGUID = self.getGUID() }) - end - if cohesionRuler ~= nil then - pcall(clearCohesionRulerOriginal) - end -end +end \ No newline at end of file diff --git a/mod/src/includes/Overlays.ttslua b/mod/src/includes/Overlays.ttslua deleted file mode 100644 index 503a2375c..000000000 --- a/mod/src/includes/Overlays.ttslua +++ /dev/null @@ -1,929 +0,0 @@ --- MacFallback.ttslua - Mac fallback for the TTS Unity 6 Projector magenta bug. --- Replaces every Custom_AssetBundle Projector overlay (Cohesion / Range / --- Maximum Move / Deployment Boundary) with a vector-lines + decal hybrid --- rendered via Global.setVectorLines / Global.setDecals. Per-seat toggle so --- Windows players keep the original Projectors. - --- Manager Global + event handlers + vector-lines rendering. --- Called by Unit_Leader / Order_Token via Global.call from their patched --- Cohesion functions. --- ============================================ - -activeOverlays = activeOverlays or {} -hiddenWhilePickedUp = hiddenWhilePickedUp or {} - --- Public hosting for the PNG assets used by the Mac fallback overlays. --- TTS loads decals from HTTP(S) URLs (Steam Workshop UGC, GitHub raw, or any --- static host). After merge, this points to the repo's mac-fallback-assets/ --- folder; before merge, replace with the PR branch URL for testing. -local ASSETS_BASE = "https://raw.githubusercontent.com/ironsquadronfr-hub/tts/mac-projector-fallback/mod/data/mac-fallback-assets/" -local COHESION_HALO_URL = ASSETS_BASE .. "cohesion_halo.png" -local MAX_MOVE_CYAN_URL = ASSETS_BASE .. "max_move_cyan.png" -local DEPLOYMENT_RED_URL = ASSETS_BASE .. "deployment_red.png" -local DEPLOYMENT_BLUE_URL = ASSETS_BASE .. "deployment_blue.png" -local RANGE_DECAL_URLS = { - smokeToken = ASSETS_BASE .. "range_smokeToken.png", - token = ASSETS_BASE .. "range_token.png", - tokenRangeTwo = ASSETS_BASE .. "range_tokenRangeTwo.png", - poi = ASSETS_BASE .. "range_poi.png", - bombCart = ASSETS_BASE .. "range_bombCart.png", - -- Fig leaders: per-base-size PNGs (bands pre-offset by the base radius so - -- the decal bands line up with the vector-line rings). - fig_leader = { - small = ASSETS_BASE .. "range_fig_leader_small.png", - medium = ASSETS_BASE .. "range_fig_leader_medium.png", - large = ASSETS_BASE .. "range_fig_leader_large.png", - huge = ASSETS_BASE .. "range_fig_leader_huge.png", - laat = ASSETS_BASE .. "range_fig_leader_laat.png", - epic = ASSETS_BASE .. "range_fig_leader_epic.png", - long = ASSETS_BASE .. "range_fig_leader_long.png", - snail = ASSETS_BASE .. "range_fig_leader_snail.png", - }, -} -local RANGE_MAX_RADIUS = { - fig_leader = 30, -- R5 max - smokeToken = 1, - token = 1, - tokenRangeTwo = 2, - poi = 3, - bombCart = 2, -} - --- Range band colors (RGBA) extracted from BB_RangeProjector material defaults --- + demi-portee blanche pour SWL Range 0.5 (3 inches) -local RANGE_COLORS = { - half = {1.0, 1.0, 1.0, 0.6}, -- 0.5 = 3" white - one = {1.0, 0.764, 0.0, 0.7}, -- R1 yellow - two = {1.0, 0.459, 0.0, 0.7}, -- R2 orange - three = {1.0, 0.079, 0.0, 0.7}, -- R3 red - four = {0.764, 0.0, 0.259, 0.7}, -- R4 magenta - five = {0.528, 0.0, 0.443, 0.7}, -- R5 violet -} - --- Per-rangeKey configurations: list of {radius_inches, color_name} -local RANGE_CONFIGS = { - -- Fig leaders: half + 4 bands at SWL standard ranges 3/6/12/18/24" - small = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - medium = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - large = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - huge = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - laat = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - epic = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - long = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - snail = {{r=3,c="half"},{r=6,c="one"},{r=12,c="two"},{r=18,c="three"},{r=24,c="four"},{r=30,c="five"}}, - -- Token rangeKeys (1 to 2 bands depending on use) - smokeToken = {{r=1,c="one"}}, - token = {{r=1,c="one"}}, - tokenRangeTwo = {{r=1,c="one"},{r=2,c="two"}}, - poi = {{r=3,c="one"}}, - bombCart = {{r=1,c="one"},{r=2,c="two"}}, -} - -local COHESION_RADIUS_IN_MAC = { - small = 3.531496, -- 27mm (halfCohesion_27mm OrthographicSize) - medium = 3.984252, -- 50mm - large = 4.377953, -- 70mm -} - --- Base radius (inches) from base diameter in mm. -local FIG_BASE_RADIUS_IN = { - small = 27 / 2 / 25.4, -- 0.5315" - medium = 50 / 2 / 25.4, -- 0.9842" - large = 70 / 2 / 25.4, -- 1.3779" - huge = 100 / 2 / 25.4, -- 1.9685" - laat = 120 / 2 / 25.4, -- 2.3622" - epic = 150 / 2 / 25.4, -- 2.9527" - long = 50 / 2 / 25.4, -- approx for oblong - snail = 50 / 2 / 25.4, -- ditto -} - -local function macFigBaseRadius(fig) - if not fig then return 0.5315 end - local data = fig.getTable("unitData") - if not data or not data.baseSize then return 0.5315 end - return FIG_BASE_RADIUS_IN[data.baseSize] or 0.5315 -end - --- Token base radii (inches) - measured from token edge for range bands -local TOKEN_BASE_RADIUS = { - smokeToken = 18.8 / 2 / 25.4, -- 0.370" - token = 25.1 / 2 / 25.4, -- 0.494" - tokenRangeTwo = 25.1 / 2 / 25.4, -- 0.494" - poi = 50.8 / 2 / 25.4, -- 1.000" - bombCart = 50.0 / 2 / 25.4, -- 0.984" -} - --- Maximum Move radii (inches), indexed by baseSize then speed 1..3 --- Source: ProjectorRadius from BB_MovementProjector materials (single variants) -local MAX_MOVE_RADIUS = { - small = { 4.547244, 6.515748, 8.484252}, -- 27mm - medium = { 5.669292, 7.637795, 9.606299}, -- 50mm - large = { 6.850394, 8.818897, 10.787401}, -- 70mm - huge = { 8.622047, 10.590551, 12.559055}, -- 100mm - laat = { 9.803149, 11.771653, 13.740157}, -- 120mm - epic = {11.574803, 13.543307, 15.511811}, -- 150mm - long = {13.051181, 15.019685, 16.988190}, -- oblong - snail = {14.527559, 16.496063, 18.464567}, -- snail -} - --- Deployment cell dimensions (width X, depth Z, inches). --- All cells live on a 6"x6" grid; sub-cells are halves/quarters of that cell. -local DEPLOYMENT_CELL_SIZES = { - r = {6, 6}, b = {6, 6}, -- full cell - rl = {6, 6}, bl = {6, 6}, -- large variant (also full cell) - rh = {6, 3}, bh = {6, 3}, -- horizontal half (3" deep) - rs = {3, 6}, bs = {3, 6}, -- vertical half (3" wide), x-offset +1.5 - rss = {3, 6}, bss = {3, 6}, -- vertical half (3" wide), x-offset -1.5 - rc = {3, 3}, bc = {3, 3}, -- corner quarter - rcc = {3, 3}, bcc = {3, 3}, -- opposite corner quarter -} - -local function macRayGroundY(x, z, offset) - local hits = Physics.cast({ - origin = {x, 30, z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 50, - }) - for _, h in ipairs(hits) do - return h.point.y + (offset or 0.05) - end - return offset or 0.05 -end - -local function macBuildRect(cx, cz, hw, hd, color, thickness) - local segPerSide = 8 - local pts = {} - local corners = { - {cx - hw, cz + hd}, {cx + hw, cz + hd}, - {cx + hw, cz - hd}, {cx - hw, cz - hd}, - } - for i = 1, 4 do - local a = corners[i] - local b = corners[i % 4 + 1] - for s = 0, segPerSide - 1 do - local t = s / segPerSide - local x = a[1] + (b[1] - a[1]) * t - local z = a[2] + (b[2] - a[2]) * t - table.insert(pts, {x, macRayGroundY(x, z), z}) - end - end - local c1 = corners[1] - table.insert(pts, {c1[1], macRayGroundY(c1[1], c1[2]), c1[2]}) - return {points = pts, color = color, thickness = thickness or 0.06} -end - -local function macBuildRing(centerPos, radius, color, thickness, ignoreObj) - local nSeg = 64 - local pts = {} - for i = 0, nSeg do - local a = (i / nSeg) * 2 * math.pi - local x = centerPos.x + radius * math.cos(a) - local z = centerPos.z + radius * math.sin(a) - local hits = Physics.cast({ - origin = {x, centerPos.y + 10, z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local y = centerPos.y + 0.05 - for _, h in ipairs(hits) do - if h.hit_object ~= ignoreObj then - y = h.point.y + 0.05 - break - end - end - table.insert(pts, {x, y, z}) - end - return {points = pts, color = color, thickness = thickness or 0.05} -end - -local macBuilders = {} - -macBuilders.range = function(fig, params) - local pos = fig.getPosition() - -- Try to resolve rangeKey from params, then from the figure's script var - local rangeKey = (params and params.rangeKey) or fig.getVar("rangeKey") - local config, decalURL, maxR, br - if rangeKey and RANGE_CONFIGS[rangeKey] then - config = RANGE_CONFIGS[rangeKey] - decalURL = RANGE_DECAL_URLS[rangeKey] - maxR = RANGE_MAX_RADIUS[rangeKey] - br = TOKEN_BASE_RADIUS[rangeKey] or 0 - else - local data = fig.getTable("unitData") - local baseSize = (data and data.baseSize) or "small" - config = RANGE_CONFIGS[baseSize] or RANGE_CONFIGS.small - decalURL = RANGE_DECAL_URLS.fig_leader[baseSize] - or RANGE_DECAL_URLS.fig_leader.small - maxR = RANGE_MAX_RADIUS.fig_leader - br = macFigBaseRadius(fig) - end - - -- SWL convention: all distances are measured from the base/token edge. - - -- Vector lines (contour) - local lines = {} - for _, band in ipairs(config) do - table.insert(lines, macBuildRing(pos, band.r + br, RANGE_COLORS[band.c], 0.1, fig)) - end - - -- Decal halo (filled bands) - flat on the ground under the fig - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then groundY = h.point.y; break end - end - - local effectiveMaxR = maxR + br - return { - lines = lines, - decals = { - { - name = "range_decal_" .. fig.getGUID(), - url = decalURL, - position = {pos.x, groundY + 0.02, pos.z}, -- below cohesion halo - rotation = {90, 0, 0}, - scale = {effectiveMaxR * 2, effectiveMaxR * 2, effectiveMaxR * 2}, - } - }, - } -end - -macBuilders.maxmove = function(fig, params) - local pos = fig.getPosition() - local baseSize = (params and params.baseSize) or "small" - local speed = (params and params.speed) or 1 - local radii = MAX_MOVE_RADIUS[baseSize] or MAX_MOVE_RADIUS.small - local r = radii[speed] or radii[1] - local color = {0.333, 0.800, 1.000, 0.7} - -- Base ring: small white outline matching the figure's base - -- (BB_MovementProjector renders a base ring on top of the cyan max-move). - local br = FIG_BASE_RADIUS_IN[baseSize] or FIG_BASE_RADIUS_IN.small - local baseColor = {1, 1, 1, 0.8} - - -- Ground raycast for decal position - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then groundY = h.point.y; break end - end - - return { - lines = { - macBuildRing(pos, r, color, 0.1, fig), -- outer cyan max-move - macBuildRing(pos, br, baseColor, 0.06, fig), -- inner white base ring - }, - decals = { - { - name = "maxmove_decal_" .. fig.getGUID(), - url = MAX_MOVE_CYAN_URL, - position = {pos.x, groundY + 0.025, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } -end - -macBuilders.deployment = function(_, params) - local pos = params.pos -- {x, y, z} cell center (after offset) - local cell = params.cell -- "r"/"b"/"rh"/... key - local size = DEPLOYMENT_CELL_SIZES[cell] or {6, 6} - local w, d = size[1], size[2] - local cx, cz = pos[1], pos[3] - local hw, hd = w / 2, d / 2 - - local isRed = cell:sub(1, 1) == "r" - local lineColor = isRed and {1, 0.15, 0.15, 0.9} or {0.15, 0.4, 1, 0.9} - local url = isRed and DEPLOYMENT_RED_URL or DEPLOYMENT_BLUE_URL - - local groundY = macRayGroundY(cx, cz, 0) - - return { - lines = { macBuildRect(cx, cz, hw, hd, lineColor, 0.08) }, - decals = { - { - name = "deployment_decal_" .. cell .. "_" .. tostring(cx) .. "_" .. tostring(cz), - url = url, - position = {cx, groundY + 0.03, cz}, - rotation = {90, 0, 0}, - scale = {w, d, 1}, - } - }, - } -end - -macBuilders.cohesion = function(fig, params) - local pos = fig.getPosition() - local data = fig.getTable("unitData") - local baseSize = (data and data.baseSize) or "small" - local r = COHESION_RADIUS_IN_MAC[baseSize] or COHESION_RADIUS_IN_MAC.small - if params and params.radius then r = params.radius end - - -- Find ground level under the fig so the flat decal sits on the table, - -- not at the fig pivot (which is above the base). - local hits = Physics.cast({ - origin = {pos.x, pos.y + 10, pos.z}, - direction = {0, -1, 0}, - type = 1, - max_distance = 20, - }) - local groundY = pos.y - for _, h in ipairs(hits) do - if h.hit_object ~= fig then - groundY = h.point.y - break - end - end - - return { - lines = { macBuildRing(pos, r, {1, 1, 1, 0.8}, 0.06, fig) }, - decals = { - { - name = "cohesion_halo_" .. fig.getGUID(), - url = COHESION_HALO_URL, - position = {pos.x, groundY + 0.04, pos.z}, - rotation = {90, 0, 0}, - scale = {r * 2, r * 2, r * 2}, - } - }, - } -end - -function macRedrawAll() - local lines, decals = {}, {} - -- Preload all texture URLs (invisible decals far below the table) so TTS - -- keeps them cached and we never get the white-square flash on respawn. - local preloadURLs = { - COHESION_HALO_URL, - RANGE_DECAL_URLS.smokeToken, - RANGE_DECAL_URLS.token, RANGE_DECAL_URLS.tokenRangeTwo, - RANGE_DECAL_URLS.poi, RANGE_DECAL_URLS.bombCart, - RANGE_DECAL_URLS.fig_leader.small, RANGE_DECAL_URLS.fig_leader.medium, - RANGE_DECAL_URLS.fig_leader.large, RANGE_DECAL_URLS.fig_leader.huge, - RANGE_DECAL_URLS.fig_leader.laat, RANGE_DECAL_URLS.fig_leader.epic, - RANGE_DECAL_URLS.fig_leader.long, RANGE_DECAL_URLS.fig_leader.snail, - MAX_MOVE_CYAN_URL, - DEPLOYMENT_RED_URL, DEPLOYMENT_BLUE_URL, - } - for i, url in ipairs(preloadURLs) do - table.insert(decals, { - name = "preload_" .. i, - url = url, - position = {0, -200 - i * 0.01, 0}, - rotation = {90, 0, 0}, - scale = {0.01, 0.01, 0.01}, - }) - end - local stale = {} - for key, entry in pairs(activeOverlays) do - local b = macBuilders[entry.type] - if b then - -- Deployment entries have no fig (params-driven). Others need a - -- live Object - drop them silently if destroyed/invalid. - local needsFig = (entry.type ~= "deployment") - if needsFig and (not entry.fig - or type(entry.fig.getPosition) ~= "function") then - stale[#stale + 1] = key - else - local ok, out = pcall(b, entry.fig, entry.params) - if ok and out then - for _, l in ipairs(out.lines or {}) do - table.insert(lines, l) - end - for _, d in ipairs(out.decals or {}) do - table.insert(decals, d) - end - else - stale[#stale + 1] = key - end - end - end - end - for _, k in ipairs(stale) do activeOverlays[k] = nil end - Global.setVectorLines(lines) - Global.setDecals(decals) -end - -function gSpawnCohesion(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":cohesion"] = { - type = "cohesion", fig = fig, params = params or {} - } - macRedrawAll() -end - -function gClearCohesion(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":cohesion"] = nil - macRedrawAll() -end - -function gToggleCohesion(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local key = fig.getGUID() .. ":cohesion" - if activeOverlays[key] then - gClearCohesion(params) - else - gSpawnCohesion(params) - end -end - --- Range polling: a slow timer (5 frames = ~12 fps) that re-draws active --- range rulers so they follow figures in real-time. Stops automatically when --- no range overlay is active anymore - avoids the overnight memory leak. -macRangePollingActive = macRangePollingActive or false - -function macRangePoll() - local hasRange = false - for _, e in pairs(activeOverlays) do - if e.type == "range" then hasRange = true; break end - end - if hasRange then - macRedrawAll() - Wait.frames(macRangePoll, 5) - else - macRangePollingActive = false - end -end - -function gSpawnRange(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":range"] = { - type = "range", fig = fig, params = params or {} - } - macRedrawAll() - if not macRangePollingActive then - macRangePollingActive = true - Wait.frames(macRangePoll, 5) - end -end - -function gClearRange(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":range"] = nil - macRedrawAll() -end - -function gToggleRange(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local key = fig.getGUID() .. ":range" - if activeOverlays[key] then - gClearRange(params) - else - gSpawnRange(params) - end -end - -function gClearAllRange() - for k, e in pairs(activeOverlays) do - if e.type == "range" then activeOverlays[k] = nil end - end - macRedrawAll() -end - -function gSpawnDeployment(params) - -- params: { cell = "r" | "b" | ..., pos = {x, y, z} } - if not params or not params.cell or not params.pos then return end - local key = "deploy:" .. params.cell .. ":" - .. tostring(params.pos[1]) .. ":" .. tostring(params.pos[3]) - activeOverlays[key] = {type = "deployment", fig = nil, params = params} - macRedrawAll() -end - -function gClearAllDeployment() - for k, e in pairs(activeOverlays) do - if e.type == "deployment" then activeOverlays[k] = nil end - end - macRedrawAll() -end - -function gSpawnMaxMove(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":maxmove"] = { - type = "maxmove", fig = fig, params = params or {} - } - macRedrawAll() -end - -function gClearMaxMove(params) - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - activeOverlays[fig.getGUID() .. ":maxmove"] = nil - macRedrawAll() -end - -function gClearAllMaxMove() - for k, e in pairs(activeOverlays) do - if e.type == "maxmove" then activeOverlays[k] = nil end - end - macRedrawAll() -end - -function onObjectPickUp(player_color, obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - local changed = false - for key, entry in pairs(activeOverlays) do - if key:sub(1, plen) == prefix then - -- Cohesion: static, hide during pickup, restore on drop. - -- Range: keep visible; the polling timer redraws each tick so - -- the ruler follows the figure in real-time during the drag. - if entry.type == "cohesion" then - hiddenWhilePickedUp[key] = entry - activeOverlays[key] = nil - changed = true - end - end - end - if changed then macRedrawAll() end -end - -function onObjectDrop(player_color, obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - local changed = false - for key, entry in pairs(hiddenWhilePickedUp) do - if key:sub(1, plen) == prefix then - activeOverlays[key] = entry - hiddenWhilePickedUp[key] = nil - changed = true - end - end - if changed then macRedrawAll() end -end - -function onObjectDestroy(obj) - if not obj or not obj.getGUID then return end - local guid = obj.getGUID() - local prefix = guid .. ":" - local plen = #prefix - local changed = false - for key in pairs(activeOverlays) do - if key:sub(1, plen) == prefix then - activeOverlays[key] = nil - changed = true - end - end - for key in pairs(hiddenWhilePickedUp) do - if key:sub(1, plen) == prefix then - hiddenWhilePickedUp[key] = nil - end - end - if changed then macRedrawAll() end -end - --- Texture preload is handled by macRedrawAll which always includes all PNG --- URLs as invisible decals far below the table. No separate preload needed. - --- ============================================ --- PER-SEAT MODE TOGGLE (Cohesion + Range) --- Each seated player picks their renderer: "mac" (this patch) or --- "windows" (original bundle Projector). When a player triggers an overlay, --- the router checks THEIR mode and uses that renderer. The rendered overlay --- is visible to everyone (TTS engine limitation). --- ============================================ - -playerOverlayMode = playerOverlayMode or {} -- color -> "mac" | "windows" -deploymentMode = deploymentMode or "mac" -- table-wide setting - -function gGetMode(params) - return playerOverlayMode[params.color] or "mac" -end - -function gGetDeploymentMode() - return deploymentMode -end - -function gToggleDeploymentMode() - deploymentMode = (deploymentMode == "mac") and "windows" or "mac" - macRefreshModeUI() -end - -function gCohesionTrigger(params) - if not params or not params.figGUID then return end - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "mac" - if mode == "windows" then - -- Object may not have the original alias (e.g., hovered non-fig object). - -- pcall guards against "no such function" errors that would otherwise - -- propagate out of the hotkey/button callback. - local ok = pcall(function() fig.call("spawnCohesionRulerOriginal", fig) end) - if not ok then gToggleCohesion({figGUID = params.figGUID}) end - else - gToggleCohesion({figGUID = params.figGUID}) - end -end - -function gRangeTrigger(params) - if not params or not params.figGUID then return end - local fig = getObjectFromGUID(params.figGUID) - if not fig then return end - local mode = playerOverlayMode[params.playerColor] or "mac" - if mode == "windows" then - local ok = pcall(function() fig.call("spawnRangeRulerOriginal", fig) end) - if not ok then gToggleRange({figGUID = params.figGUID}) end - else - gToggleRange({figGUID = params.figGUID}) - end -end - --- UI: floating panel with one toggle button per seated player -function macModeClick(player, _, id) - local color = id:sub(9) -- "macmode_Red" -> "Red" - if player.color ~= color then - broadcastToColor("Only the player at this seat can toggle their mode.", - player.color, {1, 0.5, 0.5}) - return - end - local cur = playerOverlayMode[color] or "mac" - playerOverlayMode[color] = (cur == "mac") and "windows" or "mac" - -- Wipe any active Cohesion/Range overlays for this player so the next - -- trigger redraws in the chosen mode without stale visuals. - macRefreshModeUI() -end - -local function macFindNodeById(tree, id) - for _, node in ipairs(tree) do - if node.attributes and node.attributes.id == id then return node, tree end - if node.children then - local found, parent = macFindNodeById(node.children, id) - if found then return found, parent end - end - end - return nil, nil -end - --- Tracks the panel's desired active state across refreshes. Persists so a --- click on the close X before the initial refresh is honored. -macModePanelActive = (macModePanelActive == nil) and true or macModePanelActive - -function macModeToggleVisibility() - macModePanelActive = not macModePanelActive - local tree = UI.getXmlTable() or {} - local panel = macFindNodeById(tree, "macModePanel") - if panel then - panel.attributes.active = macModePanelActive and "true" or "false" - UI.setXmlTable(tree) - else - macRefreshModeUI() - end -end - -function macRefreshModeUI() - local seated = Player.getPlayers() - local rows = {{ - tag = "Panel", - attributes = { - color = "transparent", - preferredHeight = "24", - }, - children = { - { - tag = "Text", - attributes = { - text = "Mac TTS U6 Patch", - fontSize = "16", - color = "white", - alignment = "MiddleLeft", - rectAlignment = "MiddleLeft", - }, - }, - { - tag = "Button", - attributes = { - id = "macModePanelClose", - text = "X", - onClick = "macModeToggleVisibility", - color = "#3a1e1e", - textColor = "white", - fontSize = "12", - width = "24", - height = "20", - rectAlignment = "MiddleRight", - }, - }, - }, - }, { - tag = "Text", - attributes = { - text = "Cohesion & Range: pick your renderer", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }} - if #seated == 0 then - table.insert(rows, { - tag = "Text", - attributes = { - text = "(no seated players)", - fontSize = "12", - color = "#888888", - alignment = "MiddleCenter", - }, - }) - end - for _, p in ipairs(seated) do - local mode = playerOverlayMode[p.color] or "mac" - local label = p.color .. " - " .. - (mode == "mac" and "MAC FALLBACK" or "WINDOWS ORIGINAL") - local bg = (mode == "mac") and "#1e7a3a" or "#7a3a1e" - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_" .. p.color, - text = label, - onClick = "macModeClick", - color = bg, - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - end - -- Deployment is table-wide (everyone sees the same zones); use a single - -- global toggle rather than per-seat. - table.insert(rows, { - tag = "Text", - attributes = { - text = "Deployment (table-wide)", - fontSize = "12", - color = "#bbbbbb", - alignment = "MiddleCenter", - }, - }) - table.insert(rows, { - tag = "Button", - attributes = { - id = "macmode_deployment", - text = (deploymentMode == "mac") and "DEPLOYMENT: MAC FALLBACK" - or "DEPLOYMENT: WINDOWS ORIGINAL", - onClick = "macDeploymentClick", - color = (deploymentMode == "mac") and "#1e3a7a" or "#7a3a1e", - fontSize = "13", - textColor = "white", - preferredHeight = "28", - }, - }) - -- Build my panel (will be merged into the existing UI tree below so we - -- don't clobber legionFloatingMenu / Welcome / Chess Clocks etc.). - local panel = { - tag = "Panel", - attributes = { - id = "macModePanel", - active = macModePanelActive and "true" or "false", - rectAlignment = "MiddleRight", - offsetXY = "-10 80", - width = "260", - height = "320", - color = "rgba(0.06,0.06,0.06,0.9)", - padding = "8 8 8 8", - outlineSize = "1 1", - outline = "#303030", - }, - children = {{ - tag = "VerticalLayout", - attributes = { - spacing = "4", - childForceExpandHeight = "false", - childForceExpandWidth = "true", - }, - children = rows, - }}, - } - - local tree = UI.getXmlTable() or {} - -- Remove any stale macModePanel before reinserting the fresh one. - for i = #tree, 1, -1 do - if tree[i].attributes and tree[i].attributes.id == "macModePanel" then - table.remove(tree, i) - end - end - table.insert(tree, panel) - - -- Inject a "Mac Patch" button into the bottom-right legionFloatingMenu - -- (replaces the first interactable=false placeholder button). - local menu = macFindNodeById(tree, "legionFloatingMenu") - if menu and menu.children then - local hasMine = false - for _, c in ipairs(menu.children) do - if c.attributes and c.attributes.id == "macModeMenuButton" then - hasMine = true; break - end - end - if not hasMine then - for i, c in ipairs(menu.children) do - if c.attributes and c.attributes.interactable == "false" then - menu.children[i] = { - tag = "Button", - attributes = { - id = "macModeMenuButton", - fontSize = "10", - onClick = "macModeToggleVisibility", - tooltip = "Toggle Mac TTS U6 Patch panel", - }, - value = "Mac Patch", - } - break - end - end - end - end - - UI.setXmlTable(tree) -end - -function macDeploymentClick(_, _, _) - gToggleDeploymentMode() -end - -function onPlayerChangeColor(_) macRefreshModeUI() end -function onPlayerConnect(_) macRefreshModeUI() end -function onPlayerDisconnect(_) macRefreshModeUI() end - -Wait.time(macRefreshModeUI, 2) - --- Override hotkey init functions to capture playerColor and route through --- the per-seat trigger. Defining initCohesionHotkeys/initRangebandHotkeys --- here SHADOWS the originals - when the original onLoad runs init*(), our --- versions register the hotkey instead. - -function initCohesionHotkeys() - addHotkey("Show Cohesion On Hovered Model", - function(playerColor, hoverObject, _) - if not hoverObject or not hoverObject.interactable then return end - gCohesionTrigger({ - figGUID = hoverObject.getGUID(), - playerColor = playerColor, - }) - end) -end - -function initRangebandHotkeys() - addHotkey("Show Range On Hovered Model", - function(playerColor, hoverObject, _) - if not hoverObject or not hoverObject.interactable then return end - gRangeTrigger({ - figGUID = hoverObject.getGUID(), - playerColor = playerColor, - }) - end) -end - --- Legacy global-scope wrappers (for any code that still calls these directly). --- Default to mac mode when no color context is available. -function showCohesionOnHoveredModel(hoverObject) - if not hoverObject or not hoverObject.interactable then return end - gToggleCohesion({ figGUID = hoverObject.getGUID() }) -end - -function spawnCohesionRuler(cohesionSourceObject) - if not cohesionSourceObject then return end - gSpawnCohesion({ figGUID = cohesionSourceObject.getGUID() }) -end - -function clearCohesionRuler() - for key, entry in pairs(activeOverlays) do - if entry.type == "cohesion" then activeOverlays[key] = nil end - end - macRedrawAll() -end - -function showRangeOnHoveredModel(hoverObject) - if not hoverObject or not hoverObject.interactable then return end - gToggleRange({ figGUID = hoverObject.getGUID() }) -end - -function spawnRangeRuler(rangeSourceObject, _) - if not rangeSourceObject then return end - gSpawnRange({ figGUID = rangeSourceObject.getGUID() }) -end - -function clearRangeRulers() - gClearAllRange() -end - diff --git a/mod/src/includes/RangeRulers.ttslua b/mod/src/includes/RangeRulers.ttslua index c51676e09..6df6e15a7 100644 --- a/mod/src/includes/RangeRulers.ttslua +++ b/mod/src/includes/RangeRulers.ttslua @@ -80,33 +80,3 @@ function spawnRangeRuler(rangeSourceObject, projectorBundleOverride) noRulers = false end - --- ============================================ --- Mac fallback per-seat router (appended). --- Mirrors the Cohesion pattern. See !/Overlays for the manager. --- ============================================ -spawnRangeRulerOriginal = spawnRangeRuler -clearRangeRulersOriginal = clearRangeRulers - -function spawnRangeRuler(rangeSourceObject, _) - if not rangeSourceObject then return end - Global.call("gRangeTrigger", { - figGUID = rangeSourceObject.getGUID(), - playerColor = nil, - }) -end - -function clearRangeRulers() - Global.call("gClearAllRange", {}) - -- Also run the original (destroys the bundle Object spawned in Windows - -- mode). Skip if rangeRuler was never set (pure-Mac session). - if rangeRuler ~= nil then - pcall(clearRangeRulersOriginal) - end -end - -function clearRangeRuler() - if self and self.getGUID and self.getGUID() ~= "-1" then - Global.call("gClearRange", { figGUID = self.getGUID() }) - end -end