Skip to content

fix(classic): preserve each champion's native default carrier - #318

Open
ccccxp wants to merge 8 commits into
Alban1911:mainfrom
ccccxp:main
Open

ccccxp wants to merge 8 commits into
Alban1911:mainfrom
ccccxp:main

Conversation

@ccccxp

@ccccxp ccccxp commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

This PR fixes the base carrier used by Rift Classic skin injection.

Skin0 means slot 0; 600xx000 is the full Rift Classic LCU skin ID for that
slot. Some Classic champions use Skin0 as their real default, while others use
Skin301, and Kayle currently uses Skin302. Instead of generating Skin0 for
every champion, Rose now resolves the live default slot and selects a matching
package family before changing LCU.

Regular game modes are unchanged.

Two automatically matched paths

This PR supports two complete injection paths and selects between them
automatically for each requested Classic skin:

  1. Native-default path: use the champion's live Skin0/Skin301/Skin302
    default carrier together with the matching package from classic-native/.
  2. Skin0 compatibility path: use generated 600xx000 together with the
    fully retargeted Skin0 package from classic/.

Rose first resolves the real default carrier from the intersection of the live
carousel and pickable-skin catalogs, then validates the requested archive in
both package libraries. It prefers the native-default path. If that package is
missing or structurally invalid, Rose switches both the carrier and package
library to the Skin0 compatibility path. It never combines a native carrier
with a Skin0 package, or Skin0 with a native-carrier package.

If the carrier cannot be resolved, or neither package is usable, Rose stops
before sending skip-base-skin, changing the UI, or writing the LCU selection.

The requested target skin remains separate from this carrier choice and still
uses the existing LTK injection path. Party injection follows the same selected
package family. There is no manual slot setting.

Why keep the native carrier

For Katarina:

  • regular Skin0: 55000;
  • Classic Skin0: 60055000;
  • Classic native Skin301 carrier: 60055301;
  • Battle Queen target slot 29: 60055029.

When the target overlay is unavailable after Rose has selected 60055000, the
game can still launch on the hidden slot, but the visible result is regular base
Katarina rather than the mode-native Classic default at 60055301.

Loading screen after removing the Battle Queen target package

Figure 1: the missing target leaves the loading card on the hidden Classic
Skin0 selection, 60055000.

Katarina native Classic model at 60055301

Figure 2: the expected mode-native Classic fallback at 60055301.

Regular base Katarina model reached through 60055000

Figure 3: the regular base model reached through 60055000 when no overlay
replaces it.

Skin0 injection is still supported. A fully adapted Skin0 package works when
its model, animation, weapon, material, effect, and linked-object dependencies
are retargeted correctly. The native path is preferred because the LCU default,
failure fallback, and package object graph remain aligned.

Compatible library transition

The matching resource PR is
Alban1911/LeagueSkins#158:

  • classic/ is the complete Skin0-compatible library used by released Rose
    versions and by the automatic compatibility path;
  • classic-native/ is the parallel native-default library rooted in each
    champion's live Skin0/Skin301/Skin302 carrier.

Both trees expose the same 1,737 package paths. Older Rose releases continue to
use classic/; this PR can choose either family without requiring an atomic
upgrade across the two repositories.

Scope

This PR contains carrier discovery, package preflight, dual-library download
and injection wiring, party-path consistency, focused tests, and the carrier
reference. It does not include ClassicWheel, history navigation, random mode,
the Classic chroma picker, badges, carousel projection, or background syncing.

The lookup method, 63-champion reference, package layout, and repair procedure
are documented in
docs/rift-classic-native-carriers.md.
The table is an audit reference; runtime selection comes from live LCU data.

Validation

  • 81 focused carrier, injection, library, party, custom-mod, chroma, and mode
    tests pass.
  • Native-carrier injection and the rebuilt Skin0 fallback were both verified in
    live games, including Katarina's weapon and effect dependencies.
  • Missing or structurally invalid archives are rejected before the LCU write.
  • A ZIP-valid archive with a semantically broken internal WAD still follows the
    existing late injection failure path; it cannot be safely rolled back after
    game startup begins.
  • The full macOS discovery run completed 327 tests with 3 failures and 3 errors
    limited to existing Windows-only APIs (ctypes.windll, mbcs, and patcher
    process return-code behavior); 7 tests were skipped.

The original reviewed commit bb514ad4 remains intact in this PR. The later
commits update it to Rose 1.4.9 and address the review findings incrementally.

Local logs confirm the LCU, Team Builder, and local game-spawn values described
above. They do not establish what an unmodified second client renders, and they
do not establish any Vanguard behavior.

@ccccxp

ccccxp commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

The matching clean asset-only resource branch is submitted as Alban1911/LeagueSkins#158.

It is based on the current LeagueSkins upstream main and changes only the two Rift Classic resource roots:

  • classic/ contains the complete Skin0-compatible package family used by released Rose versions and by automatic fallback.
  • classic-native/ contains the parallel Skin0/Skin301/Skin302 default-carrier family used by the revised source path.

Both trees expose the same 1,737 relative package paths. Regular skins/, resources/, and every non-Classic path remain unchanged.

Resolve the active Rift Classic carrier from the intersection of the live carousel and pickable-skin catalogs. Prefer a unique native Skin301/Skin302 slot, retain a verified Skin0 slot when no native carrier exists, and refuse to write a guessed Skin0 ID when the catalogs do not identify a safe carrier.

Keep the resolver independent from ClassicWheel. A standalone installation resolves the carrier at the injection write boundary, including when LCU already reports the generated 600xx000 Skin0 ID, and restores the cached native carrier after an LCU rollback. When ClassicWheel is installed, the same boundary reuses its already validated carrier.

Use the verified carrier for regular history and chroma default-skin comparisons without changing their ordinary-mode behavior. Document the 63-champion reference, the matching LeagueSkins package layout, and the missing-package Katarina case.

Validation:

- 17 Classic injection and carrier tests pass.

- Standalone regressions cover an initial 600xx000 selection and a later rollback from the cached native carrier.

- The wheel-first merge is validated separately before publication.

- Regular mode keeps the requested Skin0 ID unchanged.

@Alban1911 Alban1911 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed write-up and the Katarina evidence. The carrier problem is real, and resolving it from the live carousel and pickable-skin catalogs is a clean way to find it. I tested bb514ad4: the 17 tests pass and it merges into main without conflicts. Two things block it for now.

1. An unresolved carrier cancels the whole injection. When carrier_skin_id_for_force can't get a carrier (an LCU catalog call that fails or times out, or a catalog we don't expect), it raises inside _force_base_skin. _inject_unowned_skin catches that, and the injection thread never starts. I simulated it with lcu.get returning None on a JADE champion:

[INJECT] injection error: Classic native carrier is unavailable; refusing Skin0 fallback
injection thread started: False
skip-base-skin broadcast sent: True

So the player gets no skin at all, and the UI has already been told to skip the base skin. Today the same case still injects on Skin0. When the carrier can't be resolved, please fall back to the current Skin0 behavior and log it, rather than refusing the injection.

2. The library change can't ship without breaking someone. This PR needs LeagueSkins#158, which rebuilds the Classic packages of the Skin301/Skin302 champions on those slots. But every Rose version downloads the same library:

  • If #158 lands first, Rose 1.4.8 and older still select 600xx000, so packages built for Skin301 don't apply, and those champions' Classic skins break for everyone who hasn't updated.
  • If this PR lands first, Rose selects Skin301 while the packages still target Skin0, which breaks the same champions.

Before either can merge, I need a transition plan. For example, keep both package sets side by side, have this version of Rose pick the Skin301 ones, and drop the Skin0 ones once older versions are gone.

Also, a question: the PR shows the failure when a target package is missing. With the package present, do the current Skin0 packages show anything wrong in-game for the Skin301/Skin302 champions? If they don't, the gain is the fallback when a package is missing, and I'd like to weigh that against a coordinated change across both repos.

Preserve the original reviewed commit bb514ad while integrating the current upstream baseline. The merge tree matches the already validated 1.4.9 carrier root before the follow-up fixes.
Replace the fail-closed carrier path with one automatic decision: use a verified native carrier only when the matching classic-native archive resolves, otherwise keep the generated Skin0 carrier and the legacy classic package.

Resolve the decision before broadcasting skip-base-skin, pass the package family explicitly through the injection thread, and keep party archive resolution on the same family. Download and clean classic-native independently so the existing classic library remains compatible with released Rose versions.

Validation: 66 focused carrier, injection, downloader, and party tests pass. The full macOS suite still has six pre-existing Windows-only failures involving ctypes.windll, mbcs, and patcher process return codes.
Prefer the classic-native package family for every verified live default carrier, including champions whose native carrier is Skin0. This keeps the package source aligned with the champion's real default slot instead of treating every Skin0 carrier as a reason to use the compatibility library.

Preflight both candidate archives before changing the LCU selection. If the default-slot package is missing or invalid, fall back atomically to the existing classic Skin0 package; native Skin0 carriers keep the same slot and only change package source. Abort without an LCU write when neither package is usable.

Cache ZIP validation by path, size, and modification time so repeated carousel events do not reread unchanged packages. Invalid archives, CRC failures, and damaged compressed streams are treated as unavailable. Extraction and LTK failures after a valid preflight remain on the existing injection failure path.

Validation: 77 focused Classic, library, party, and custom-mod tests pass; Python compile checks pass.
Use the same feature-detected injection boundary as the wheel PR. The carrier branch still supplies native discovery, package validation, and dual-library support, while the wheel remains functional on upstream Skin0 when this module is absent.

Pass the native package option only when the automatic plan selects it. This keeps the two PRs independently usable and avoids a second carrier implementation inside the carousel.

Validation: Python compilation and git diff checks pass for the injection trigger.
Expose Classic champion detection, ID conversion, catalog normalization, and state-aware default-slot helpers from injection.classic. Keep classic_carrier focused on live carrier discovery and automatic package policy, and import shared predicates directly at the injection boundary.

The shared module is intentionally identical to the wheel PR so the optional carrier and carousel changes remain independently usable and merge without competing implementations.

Validation: Python compilation and git diff checks pass for the changed modules.
Return the regular champion's Skin0 ID before importing the optional carrier policy or probing either Classic package family. This keeps the compatibility boundary inert outside Rift Classic and prevents ordinary injection from touching classic/ or classic-native/.

Validation: 78 focused carrier, library, party, and custom-mod tests pass. The regression test verifies that regular mode performs no Classic package or LCU catalog lookup.
Resolve and cache the live Rift Classic default slot before probing either package family. If the carrier cannot be identified, stop before package preflight or any LCU write instead of treating an unknown slot as permission to use generated Skin0.

After a carrier is known, keep the automatic package order unchanged: use the matching classic-native archive first, switch carrier and package together to the legacy classic Skin0 archive when necessary, and abort when neither archive is usable.

Remove carrier substitution from the generic base-skin force path because it has no selected package context and is also used by custom mods. Official Classic skin injection continues through the validated automatic plan.

Validation: 81 focused carrier, library, party, custom-mod, chroma, and mode tests pass. The unresolved-carrier regression verifies that package probes, LCU writes, and injection startup do not occur.
@ccccxp

ccccxp commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. The main result first: the two-path compatibility mode is now complete and working.

Rose now supports both Rift Classic carrier layouts automatically:

  1. Native-default path: use the champion's live Skin0/Skin301/Skin302 carrier with the matching package from classic-native/.
  2. Skin0 compatibility path: use 600xx000 with the fully retargeted package from classic/.

Rose prefers the native-default path. If its package is unavailable, Rose switches the carrier and package library together to the Skin0 path. The two package families are never mixed, and there is no manual carrier setting.

The Skin0 solution is technically valid. I rebuilt and tested the Skin0 packages, including Katarina's weapons, daggers, effects, materials, animations, and linked objects. When those dependencies are fully retargeted, Skin0 injects correctly in game. The earlier weapon and effect problems were caused by an incomplete Skin0 object graph, not by Skin0 itself being unusable.

Additional log evidence

I also found new log evidence that explains why I still prefer the native carrier when it is available.

Katarina's live Classic default is 60055301, while the Skin0 compatibility path selects 60055000. The game can still start and a complete overlay can still work, but the client and game-session logs record hidden Skin0 / SkinID(0) instead of the mode-native Skin301 slot.

Rose runtime log:

artifacts/rose-source-runtime/logs/rose-source-*.log

[CLASSIC:CARRIER] Auto fallback champion=60055 carrier=60055000 package=legacy reason=default-package-unavailable
[CLASSIC:INJECT] Forcing carrier skin (skinId=60055000)
LCUSkinID: 60055000
CarrierSkinID: 60055301

League client log:

League of Legends (PBE)/Logs/LeagueClient Logs/*_LeagueClient.log

"selectedSkinId":60055000
"skinId":0

Game log:

League of Legends (PBE)/Logs/GameLogs/**/_r3dlog.txt

ROST| CONNECTION READY ... Champion(Jade_Katarina) SkinID(0) ...
SPN| Spawn Champion Request Jade_Katarina ... SkinID(0) ...

This does not prove that Skin0 is rejected, detected by Vanguard, or causes a ban. It does show that the recorded identity differs from the live Classic default. That difference may create anomalous telemetry and could carry additional account or enforcement risk. For that reason, I prefer the native path while keeping Skin0 as a tested compatibility fallback. A two-client test is still needed to confirm what an unmodified teammate sees.

Responses to the review points

1. Unresolved carrier and partial injection

You were right that the previous ordering was unsafe: Rose could send skip-base-skin and then fail before the injection thread started.

Carrier discovery and package checks now finish before Rose changes LCU, sends skip-base-skin, or starts injection. I made one conservative adjustment to the requested behavior: if the live carrier itself cannot be resolved, Rose stops before any side effect instead of guessing Skin0. Once the carrier is known, package fallback to Skin0 is automatic. If neither package is usable, Rose also stops before the LCU write.

2. Safe library transition

LeagueSkins#158 now keeps both complete package families side by side with the same 1,737 relative paths:

  • classic/ remains available to released Rose versions and to automatic Skin0 fallback;
  • classic-native/ provides the matching packages for native Skin0, Skin301, and Kayle's Skin302.

Older Rose versions continue reading classic/. The revised Rose selects the carrier and directory together, so the repositories no longer require an atomic release and landing either PR first does not break the existing Skin0 path.

3. Does Skin0 work when the target package is present?

Yes. After rebuilding the complete dependency graph, the Skin0 path works in game. The native path is preferred because it requires less retargeting and keeps the LCU default, package root, and no-overlay fallback aligned; it is not preferred because Skin0 is technically impossible.

4. Remaining damaged-package boundary

Missing and structurally invalid archives are rejected before the LCU write. A ZIP-valid package with a semantically broken internal WAD can still fail later in mkoverlay, after game startup has begun. Rose cannot safely rewrite the selected carrier at that point, so this remains on the existing late injection failure path and is now documented explicitly.

Both paths have been verified in live games, and 81 focused carrier, injection, library, party, custom-mod, chroma, and mode tests pass. Regular modes remain on the existing injection path, and party injection uses the same package family selected for the local player. The original reviewed commit bb514ad4 remains intact; the later commits contain the compatibility and review fixes.

Since Rift Classic has also been announced for removal, I kept this as a small automatic compatibility layer rather than adding a permanent settings switch. The Classic-specific code and resources remain isolated so they can be disabled or removed later without changing regular injection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants