Загрузка данных


================================================================
  SHATTERED GENESIS — «Расколотый Первомир»
  ПОЛНЫЙ ОТЧЁТ КОДА · v0.4.26.20
  G20: кульминация «Касание Стены Света»
  Дата пересборки отчёта: 2026-08-12
================================================================

================================================================
=== ФАЙЛ: Assets/Atlases/StoryParticles.json
================================================================
{
  "frames": {
    "Ember": {
      "frame": {
        "x": 0,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "rotated": false,
      "trimmed": false,
      "spriteSourceSize": {
        "x": 0,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "sourceSize": {
        "w": 16,
        "h": 16
      }
    },
    "Firefly": {
      "frame": {
        "x": 16,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "rotated": false,
      "trimmed": false,
      "spriteSourceSize": {
        "x": 0,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "sourceSize": {
        "w": 16,
        "h": 16
      }
    }
  },
  "meta": {
    "app": "free-tex-packer (human-made by SG techdept)",
    "image": "StoryParticles.png",
    "size": {
      "w": 32,
      "h": 16
    },
    "scale": "1"
  }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/CampDarkness.fx
================================================================
// CampDarkness.fx — абсолютная сюжетная ночь вокруг привала.
// Luminance автоматически регистрирует фильтр как
// "ShatteredGenesis.CampDarkness" и подаёт uImage0/opacity/screenSize.

sampler uImage0 : register(s0);

float4x4 MatrixTransform;
float2 screenSize;
float opacity;
float globalTime;

float2 lightCenter; // координаты костра в UV экрана
float lightRadius;  // радиус по высоте экрана
float darkness;     // 0..1

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput DarknessVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 DarknessPS(float2 uv : TEXCOORD0) : COLOR0
{
    float4 world = tex2D(uImage0, uv);

    // Круг остаётся кругом на широком мониторе.
    float2 delta = uv - lightCenter;
    delta.x *= screenSize.x / max(screenSize.y, 1.0);
    float distanceFromFire = length(delta);

    // Внутри костра изображение живёт, на мягкой кромке быстро исчезает,
    // за пределами outer — математический ноль, а не полупрозрачная виньетка.
    float inner = lightRadius * 0.58;
    float outer = lightRadius;
    float visibility = 1.0 - smoothstep(inner, outer, distanceFromFire);

    // Небольшое дыхание света не расширяет внешний абсолютный ноль.
    float breath = 0.94 + 0.06 * sin(globalTime * 8.0);
    float core = 1.0 - smoothstep(0.0, max(inner, 0.001), distanceFromFire);
    float3 warm = float3(1.0, 0.34, 0.055) * core * 0.10 * breath;

    float3 extinguished = world.rgb * visibility * breath + warm * visibility;
    float strength = saturate(opacity * darkness);
    world.rgb = lerp(world.rgb, extinguished, strength);
    return world;
}

technique CampDarknessTechnique
{
    pass AutoloadPass
    {
        VertexShader = compile vs_2_0 DarknessVS();
        PixelShader = compile ps_2_0 DarknessPS();
    }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/DistortionVortex.fx
================================================================
// DistortionVortex.fx — мягкая космическая воронка под Оком Создателя.

sampler uImage0 : register(s0);

float4x4 MatrixTransform;
float opacity;
float globalTime;
float2 screenSize;

float2 vortexCenter;   // UV центра портала
float vortexRadius;    // радиус в долях высоты экрана
float vortexStrength;  // 0..1

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput VortexVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 VortexPS(float2 uv : TEXCOORD0) : COLOR0
{
    float aspect = screenSize.x / max(screenSize.y, 1.0);
    float2 delta = uv - vortexCenter;
    float2 corrected = float2(delta.x * aspect, delta.y);
    float radius = length(corrected);
    float mask = 1.0 - smoothstep(vortexRadius * 0.25, vortexRadius, radius);

    float angle = mask * vortexStrength * opacity *
        (2.1 + 0.18 * sin(globalTime * 2.0));
    float sine = sin(angle);
    float cosine = cos(angle);
    float2 rotated = float2(
        corrected.x * cosine - corrected.y * sine,
        corrected.x * sine + corrected.y * cosine);
    rotated.x /= aspect;

    float2 sampleUV = vortexCenter + rotated;
    float inwardPull = mask * vortexStrength * 0.08;
    sampleUV = lerp(sampleUV, vortexCenter, inwardPull);

    float4 color = tex2D(uImage0, sampleUV);
    color.rgb += float3(1.0, 0.48, 0.08) * mask * 0.08 * vortexStrength;
    return color;
}

technique DistortionVortexTechnique
{
    pass AutoloadPass
    {
        VertexShader = compile vs_2_0 VortexVS();
        PixelShader = compile ps_2_0 VortexPS();
    }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/LightStrike.fx
================================================================
// LightStrike.fx — ослепительный удар Стены Света.
// Важно: uImage0 не содержит отдельной маски игрока. Поэтому чёрный силуэт
// задаётся математической капсулой по экранному центру и размеру персонажа,
// которые LightWallEvent передаёт каждый кадр.

sampler uImage0 : register(s0);

float4x4 MatrixTransform;
float opacity;
float globalTime;
float2 screenSize;

float flashAmount;      // 0..1: сила белого удара
float2 playerCenter;    // UV центра груди игрока
float2 playerHalfSize;  // UV полуразмера силуэта

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput StrikeVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float CapsuleMask(float2 uv)
{
    float2 local = (uv - playerCenter) / max(playerHalfSize, float2(0.0001, 0.0001));

    // Вертикальная капсула: торс + округлённые голова/ноги.
    float2 q = float2(abs(local.x), abs(local.y) - 0.55);
    float distanceToCapsule = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
    return 1.0 - smoothstep(0.78, 0.98, distanceToCapsule);
}

float4 StrikePS(float2 uv : TEXCOORD0) : COLOR0
{
    float4 world = tex2D(uImage0, uv);
    float strength = saturate(opacity * flashAmount);
    float silhouette = CapsuleMask(uv) * strength;

    float3 whiteWorld = lerp(world.rgb, float3(1.0, 1.0, 1.0), strength);
    whiteWorld = lerp(whiteWorld, float3(0.0, 0.0, 0.0), silhouette);
    return float4(whiteWorld, world.a);
}

technique LightStrikeTechnique
{
    pass AutoloadPass
    {
        VertexShader = compile vs_2_0 StrikeVS();
        PixelShader = compile ps_2_0 StrikePS();
    }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/SleepFade.fx
================================================================
// ============================================================
// SleepFade.fx — экранный ФИЛЬТР сна (финал сцены «Привал»).
// Канон 0.4.26 «Глубокая интеграция»: затемнение засыпания идёт
// через экранный конвейер Luminance ManagedScreenFilter
// (AutoloadedEffects/Filters → регистрируется как
// "ShatteredGenesis.SleepFade").
//
// УСТАВ ФИЛЬТРОВ LUMINANCE (ManagedScreenFilter.cs, сверено):
//   * фильтр активируется каждый кадр вызовом Activate();
//   * Opacity растёт/падает сама (0.015 за тик) и подаётся в шейдер
//     АВТОМАТИЧЕСКИМ параметром "opacity";
//   * "screenSize" и "globalTime" ставятся менеджером сами;
//   * экран мира приезжает текстурой в слоте 0 (uImage0);
//   * пасс техники ОБЯЗАН зваться AutoloadPass.
// ============================================================

sampler uImage0 : register(s0);

float4x4 MatrixTransform;
float2 screenSize;
float opacity;      // авто: текущая сила фильтра (0..1, догоняется менеджером)
float globalTime;   // авто: мировые часы
float sceneFade;    // финал пролога: засыпание/пробуждение, 0..1

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput FadeVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 FadePS(float2 uv : TEXCOORD0) : COLOR0
{
    float4 color = tex2D(uImage0, uv);

    // Засыпание: гасим яркость неравномерно — края вянут быстрее
    // (сон начинается с периферии зрения), центр держится дольше.
    float2 c = uv - 0.5;
    c.x *= screenSize.x / screenSize.y;
    float d = length(c);

    float dim = opacity * (0.45 + 0.55 * saturate(d * 1.7));
    float flicker = 1.0 - 0.02 * sin(globalTime * 7.0) * opacity; // конвульсия век

    color.rgb *= (1.0 - saturate(dim)) * flicker;

    // Финал пролога: после обычной виньетки весь кадр честно сходит в ноль.
    color.rgb *= 1.0 - saturate(sceneFade);
    return color;
}

technique FadeTechnique
{
    pass AutoloadPass
    {
        VertexShader = compile vs_2_0 FadeVS();
        PixelShader = compile ps_2_0 FadePS();
    }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Shaders/SleepVeil.fx
================================================================
// ============================================================
// SleepVeil.fx — пелена экрана загрузки подмира.
// Канон 0.4.26 «Глубокая интеграция»: виньетка НЕ полосами
// прямоугольников, а одним экранным проходом шейдера через
// Luminance ShaderManager (AutoloadedEffects/Shaders).
//
// Путь внутри мода выбран ПРАВИЛОМ Luminance (ShaderManager.cs,
// сверено с исходником): всё, что лежит в "AutoloadedEffects/Shaders",
// библиотека сама компилирует и регистрирует под именем
// "ShatteredGenesis.SleepVeil".
//
// Параметры ставит DecoyWorld.DrawSetup через TrySetParameter.
// ============================================================

float4x4 MatrixTransform;   // конвенция SpriteBatch: движок сам кладёт сюда матрицу
float2 screenSize;          // физические пиксели вьюпорта (коррекция искажения круга)
float opacity;              // сила пелены (0..1) — вместе с вплытием арта
float globalTime;           // часы «дыхания» углей

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput VeilVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 VeilPS(float2 uv : TEXCOORD0) : COLOR0
{
    // Радиальная виньетка: чем дальше от центра — тем глуше ночь.
    // Коррекция по горизонтали, чтобы овал не растягивался монитором.
    float2 c = uv - 0.5;
    c.x *= screenSize.x / screenSize.y;
    float d = length(c) * 1.5;

    float veil = saturate(d * d);                     // мягкий квадратичный край
    float breath = 0.05 + 0.04 * sin(globalTime * 1.65); // дыхание углей в темноте
    float a = opacity * saturate(veil * (0.94 + breath));

    // Тёплый отсвет очага снизу — костёр сна где-то за краем кадра.
    float hearth = saturate(1.0 - uv.y);
    float3 col = float3(0.910, 0.345, 0.047) * (hearth * hearth) * 0.22;

    return float4(col, a);
}

technique VeilTechnique
{
    pass AutoloadPass   // имя пасса — по уставу Luminance (ManagedShader.DefaultPassName)
    {
        VertexShader = compile vs_2_0 VeilVS();
        PixelShader = compile ps_2_0 VeilPS();
    }
}

================================================================
=== ФАЙЛ: Common/Commands/DecoyTestCommand.cs
================================================================
using SubworldLibrary;
using Terraria;
using Terraria.ModLoader;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Common.Systems;

// Отладочная команда для M2: /sgworld
// Если мы в Первомире — прыгаем в мир-обманку.
// Если в обманке — возвращаемся в Первомир.
// Позже её заменит настоящий ивент «Вселение»; команда останется как инструмент тестера.
namespace ShatteredGenesis.Common.Commands
{
    public class DecoyTestCommand : ModCommand
    {
        public override string Command => "sgworld";

        // Команда работает в чате внутри мира.
        public override CommandType Type => CommandType.World;

        public override void Action(CommandCaller caller, string input, string[] args)
        {
            if (WorldFlagsSystem.ShellDestroyed)
            {
                caller.Reply("Скорлупа разрушена Вселением — пути в обманку больше нет.");
                return;
            }

            if (SubworldSystem.IsActive<DecoyWorld>())
            {
                caller.Reply("…возвращаемся в Первомир…");
                SubworldSystem.Exit();
            }
            else
            {
                caller.Reply("…проваливаемся в мир-обманку…");
                // Перенос ведёт единая кнопка истории; звуки генерации
                // подавляет ограниченный временем SubworldAudioSilencer.
                StoryDirector.EnterStory();
            }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Commands/StoryReplayCommand.cs
================================================================
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

// Команда /sgstory — прогоняет сюжетный пролог с самого начала.
// Отладочный инструмент сценарной группы: поправил текст, записал реплику,
// Build + Reload → /sgstory → смотрим сцену целиком, мира не трогая.
namespace ShatteredGenesis.Common.Commands
{
    public class StoryReplayCommand : ModCommand
    {
        public override CommandType Type => CommandType.Chat | CommandType.World;
        public override string Command => "sgstory";
        public override string Description => "Прогнать сюжетный пролог заново (отладка сцен)";

        public override void Action(CommandCaller caller, string input, string[] args)
        {
            CampfireScene.Begin(force: true);
            caller.Reply("Shattered Genesis: поднимаем сцену «Привал» заново.");
        }
    }
}

================================================================
=== ФАЙЛ: Common/Players/AnomalyPlayer.cs
================================================================
using Microsoft.Xna.Framework;
using SubworldLibrary;
using Terraria;
using Terraria.DataStructures;
using Terraria.GameInput;
using Terraria.ModLoader;
using Terraria.ModLoader.IO; // TagCompound для сохранения данных игрока
using ShatteredGenesis.Common.Systems;
using ShatteredGenesis.Content.Subworlds;

// AnomalyPlayer — персонаж-Аномалия.
// Хранит метку Вселения (IsAnomaly) и направляет сюжетный перенос.
//
// КАНОН 0.4.26 «Глубокая интеграция» — разводим миры по полочкам:
//   ОСНОВНОЙ мир — ВАНИЛЬ БЕЗ ЕДИНОЙ ПРАВКИ. Мы здесь только
//     бросаем игрока в мир истории на первом заходе (и то — уже
//     после того, как ваниль полностью закончила загрузку).
//   ПОДМИР — мир истории: вход = принудительное возвращение мыши
//     и ввода (требование №4: на загрузочной катсцене курсор скрыт,
//     а диалог «Привал» кликается мышью), потом — сцена у костра.
//
// Убито навсегда: WorldAudioGuard, фиксация игрока, чёрные фоны
// основного мира. Всё это заменено глубокими каналами (StoryDirector).
namespace ShatteredGenesis.Common.Players
{
    public class AnomalyPlayer : ModPlayer
    {
        // Метка Вселения. Сейчас ничего не делает механически,
        // но уже живёт и сохраняется вместе с персонажем.
        public bool IsAnomaly;

        public override void SaveData(TagCompound tag)
        {
            tag["sg_isAnomaly"] = IsAnomaly;
        }

        public override void LoadData(TagCompound tag)
        {
            IsAnomaly = tag.TryGet("sg_isAnomaly", out bool anomaly) && anomaly;
        }

        // Движок зовёт каждый раз при входе в мир (и в подмир тоже).
        public override void OnEnterWorld()
        {
            // Баг №3 (канон 0.4.24): ни в одном мире не терпим двух Гидов.
            WorldFlagsSystem.DedupeGuides();

            // Водяной знак сборки — чтобы на скринах всегда была версия.
            Main.NewText($"Shattered Genesis v{Mod.Version} загружен.", 140, 140, 140);

            if (SubworldSystem.AnyActive())
            {
                // Подмир уже полностью загружен: только теперь удаляем
                // неслышимые остатки генерации и возвращаем громкость игрока.
                SubworldAudioSilencer.End();

                // ============ ПОДМИР: мир истории ============
                // Требование №4 + указ «Фикс мыши» (0.4.26-2): на входе
                // мышь и ввод возвращаются ПРИНУДИТЕЛЬНО. Курсор был скрыт
                // на загрузочной катсцене ходьбы по лесу (там нечего
                // кликать), здесь же — кликабельный диалог.
                Main.cursorScale = 1f;

                // Игровой канал ввода/зума: экранные матрицы загрузки больше
                // не действуют — мир рисуется мировым зумом движка.
                PlayerInput.SetZoom_World();

                if (!WorldFlagsSystem.IntroDone)
                {
                    CampfireScene.Begin(); // ночная сцена «Привал» у костра
                }
                else
                {
                    Main.NewText("Где-то за краем мира что-то заметило, что ты пришёл.",
                        255, 190, 90);
                }
                return;
            }

            // ============ ОСНОВНОЙ мир / Первомир ============
            DecoyWorld.FinalizePendingDestruction();

            // После Стены игрок материализуется на утверждённом спавне у
            // кратера. Масштаб и управление восстанавливаются до первого кадра.
            if (LightWallEvent.ConsumePendingPrimordialWake())
            {
                LightWallEvent.RestorePlayerVisualScale();
                Player.velocity = Microsoft.Xna.Framework.Vector2.Zero;
                Player.position = new Microsoft.Xna.Framework.Vector2(
                    Main.spawnTileX * 16f - Player.width * 0.5f,
                    Main.spawnTileY * 16f - Player.height);
            }

            if (Main.gameMenu || WorldFlagsSystem.InfusionDone
                || WorldFlagsSystem.ShellDestroyed)
                return;

            // До Касания Стены выбранный сейв всегда открывает сохранённую
            // Скорлупу. После Вселения флаги выше навсегда оставляют Первомир.
            StoryDirector.EnterStory();
        }

        // После всех PlayerDrawLayer масштабируем готовые DrawData вокруг
        // центра персонажа. Это официальный TransformDrawData, а не
        // несуществующее поле Player.scale.
        public override void TransformDrawData(ref PlayerDrawSet drawInfo)
        {
            float visualScale = LightWallEvent.PlayerVisualScale;
            float blackness = LightWallEvent.PlayerSilhouetteStrength;
            if (visualScale >= 0.999f && blackness <= 0.001f)
                return;

            Vector2 pivot = Player.MountedCenter - Main.screenPosition;
            for (int i = 0; i < drawInfo.DrawDataCache.Count; i++)
            {
                DrawData data = drawInfo.DrawDataCache[i];
                data.position = pivot + (data.position - pivot) * visualScale;
                data.scale *= visualScale;
                data.color = Color.Lerp(data.color, Color.Black, blackness);
                drawInfo.DrawDataCache[i] = data;
            }
        }

        // Первый заслон ввода: движок вызывает его сразу после опроса клавиш.
        // Финальная страховка остаётся в CampfireScene.PostUpdateEverything.
        public override void SetControls()
        {
            if (CampfireScene.PlayerLocked)
                CampfireScene.ApplyPlayerLock(Player);
        }

        public override void PreUpdateMovement()
        {
            if (CampfireScene.PlayerLocked)
                CampfireScene.ApplyPlayerLock(Player);
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/CampfireScene.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics;
using SubworldLibrary;
using Terraria;
using Terraria.DataStructures;
using Terraria.GameContent;
using Terraria.Graphics;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.UI;
using Terraria.UI.Chat;
using ShatteredGenesis.Content.NPCs;
using ShatteredGenesis.Content.Particles;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Content.Tiles;

// ============================================================
// CampfireScene — реальная сцена «Привал» внутри DecoyWorld.
//
// Законы сцены:
//   * игрок с первого кадра сидит на физическом CampLog и не может встать;
//   * вокруг — глубокая ночь и фильтр Luminance CampDarkness: внешний мир
//     становится абсолютной чернотой, виден только круг нашего костра;
//   * Сказитель — настоящий ModNPC. ПКМ по нему начинает диалог;
//   * при диалоге ванильный UI скрыт, камера плавно сводит игрока и NPC;
//   * на узле GuideArrivalNode из темноты приходит настоящий NPCID.Guide;
//   * после 27-го узла Сказитель уходит и растворяется, камера возвращается,
//     управление отдаётся игроку, Гид остаётся сидеть у гаснущего костра.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
    public sealed class CampfireScene : ModSystem
    {
        enum ScenePhase
        {
            Inactive,
            AwaitingInteraction,
            Dialogue,
            StorytellerLeaving,
            WakeFadeOut,
            WakeFadeIn
        }

        sealed class DNode
        {
            public string Text;
            public string[] Choices;
            public int[] Next;
            public int Slide = -1;
        }

        // Узлы 0..26. На этом узле в круг света входит Гид.
        public const int GuideArrivalNode = 20;

        static ScenePhase phase;
        static DNode[] nodes;
        static int nodeIndex = -1;
        static int clickCooldown;
        static int finalTicks;

        static bool previousLeft;
        static bool previousRight;
        static Rectangle[] choiceRects = Array.Empty<Rectangle>();

        static int storytellerIndex = -1;
        static int guideIndex = -1;
        static bool storytellerGone;
        static bool guideSeated;
        static int guideArrivalTicks;

        static float cinematicZoom = 1f;
        static float fireStrength;
        static float sleepFade;
        static bool wakeTriggered;
        static bool darknessFilterDead;

        static bool hideUiCaptured;
        static bool previousHideUi;

        static Point playerSeatTile;
        static Point guideSeatTile;
        static Vector2 campfireWorld;
        static Vector2 playerSeatWorld;
        static Vector2 storytellerAnchor;
        static Vector2 guideApproachStart;

        static bool sceneArtTried;
        static Texture2D[] slideArt;
        static Texture2D[][] slideFrames;

        public static bool Active => phase != ScenePhase.Inactive;
        public static bool SuppressHostileSpawns => Active
            && SubworldSystem.IsActive<DecoyWorld>();
        public static bool PlayerLocked => phase is ScenePhase.AwaitingInteraction
            or ScenePhase.Dialogue or ScenePhase.StorytellerLeaving
            or ScenePhase.WakeFadeOut;
        public static bool MarkerVisible => phase == ScenePhase.AwaitingInteraction;
        public static bool StorytellerShouldLeave => phase == ScenePhase.StorytellerLeaving;
        public static Vector2 StorytellerAnchor => storytellerAnchor;

        public override void Unload()
        {
            ResetScene(false);
            slideArt = null;
            slideFrames = null;
            nodes = null;
            sceneArtTried = false;
        }

        public override void OnWorldUnload() => ResetScene(false);

        // -------------------- запуск и геометрия --------------------

        public static void Begin(bool force = false)
        {
            if (!SubworldSystem.IsActive<DecoyWorld>())
                return;
            if (Active && !force)
                return;

            ResetScene(false);
            BuildNodes();
            LoadSceneArt();
            SetupSceneGeometry();
            RemoveVanillaGuides();
            PurgeHostileNPCs();
            SpawnStoryteller();

            Main.dayTime = false;
            Main.time = 16200;
            Main.raining = false;
            Main.windSpeedCurrent = 0f;
            Main.windSpeedTarget = 0f;

            phase = ScenePhase.AwaitingInteraction;
            fireStrength = 0f;
            cinematicZoom = 1f;
            sleepFade = 0f;
            wakeTriggered = false;
            nodeIndex = -1;
            clickCooldown = 0;
            finalTicks = 0;
            storytellerGone = false;
            guideIndex = -1;
            guideSeated = false;
            guideArrivalTicks = 0;
            darknessFilterDead = false;
            previousLeft = Main.mouseLeft;
            previousRight = Main.mouseRight;

            ApplyPlayerLock(Main.LocalPlayer);
        }

        static void SetupSceneGeometry()
        {
            int centerX = Math.Clamp(Main.spawnTileX, 60, Main.maxTilesX - 60);
            int centerGround = FindSurfaceY(centerX);

            int playerX = centerX - 5;
            int playerGround = FindSurfaceY(playerX);
            playerSeatTile = new Point(playerX, playerGround - 1);

            int guideX = centerX + 6;
            int guideGround = FindSurfaceY(guideX);
            guideSeatTile = new Point(guideX, guideGround - 1);

            EnsureStoryCampfire(centerX, centerGround);
            campfireWorld = FindStoryCampfireWorld(centerX, centerGround);
            playerSeatWorld = new Vector2((playerX + 1) * 16f, playerGround * 16f);
            storytellerAnchor = new Vector2((centerX + 3) * 16f + 8f,
                FindSurfaceY(centerX + 3) * 16f);

            // Точная точка старта Гида вычисляется на ноде 20 от текущего
            // края экрана, а не заранее от условной координаты карты.
            guideApproachStart = Vector2.Zero;

            PlaceCampLog(playerSeatTile);
            PlaceCampLog(guideSeatTile);
        }

        static void EnsureStoryCampfire(int centerX, int groundY)
        {
            int type = ModContent.TileType<StoryCampfireTile>();
            if (HasStoryCampfireNear(centerX, groundY))
                return;

            // Миграционная страховка для уже сохранённых Скорлуп G9/G10.
            // В новых мирах этот же объект ставит PlaceSceneSpawn.
            for (int x = centerX - 1; x <= centerX + 1; x++)
            {
                for (int y = groundY - 2; y < groundY; y++)
                    WorldGen.KillTile(x, y, false, false, true);

                if (!Framing.GetTileSafely(x, groundY).HasTile)
                    WorldGen.PlaceTile(x, groundY, TileID.Dirt, true, true);
            }

            WorldGen.PlaceObject(centerX, groundY - 1, type, true);
            if (Main.netMode == NetmodeID.Server)
                NetMessage.SendTileSquare(-1, centerX, groundY - 1, 5);
        }

        static bool HasStoryCampfireNear(int centerX, int groundY)
        {
            int type = ModContent.TileType<StoryCampfireTile>();
            for (int x = centerX - 5; x <= centerX + 5; x++)
            {
                for (int y = groundY - 5; y <= groundY + 1; y++)
                {
                    Tile tile = Framing.GetTileSafely(x, y);
                    if (tile.HasTile && tile.TileType == type)
                        return true;
                }
            }
            return false;
        }

        static Vector2 FindStoryCampfireWorld(int centerX, int groundY)
        {
            int type = ModContent.TileType<StoryCampfireTile>();
            for (int x = centerX - 5; x <= centerX + 5; x++)
            {
                for (int y = groundY - 5; y <= groundY + 1; y++)
                {
                    Tile tile = Framing.GetTileSafely(x, y);
                    if (!tile.HasTile || tile.TileType != type)
                        continue;

                    int topX = x - tile.TileFrameX / 18 % 3;
                    int topY = y - tile.TileFrameY / 18 % 2;
                    return new Vector2(topX * 16f + 24f, (topY + 2) * 16f);
                }
            }

            return new Vector2(centerX * 16f + 8f, groundY * 16f);
        }

        static Vector2 CampfireFlameWorld => campfireWorld + new Vector2(0f, -20f);

        static int FindSurfaceY(int x)
        {
            int start = Math.Clamp(Main.spawnTileY - 90, 10, Main.maxTilesY - 20);
            for (int y = start; y < Main.maxTilesY - 20; y++)
            {
                Tile tile = Framing.GetTileSafely(x, y);
                if (tile.HasTile && Main.tileSolid[tile.TileType])
                    return y;
            }
            return Math.Clamp(Main.spawnTileY + 3, 20, Main.maxTilesY - 20);
        }

        static void PlaceCampLog(Point tilePosition)
        {
            if (!WorldGen.InWorld(tilePosition.X, tilePosition.Y, 10))
                return;

            int type = ModContent.TileType<CampLog>();
            int left = tilePosition.X - 1; // Origin CampLog = (1, 0)

            // Подготавливаем честную опору под все четыре сегмента и удаляем
            // старую одноклеточную заглушку из сохранений G17 и младше.
            for (int x = left; x < left + CampLog.Width; x++)
            {
                WorldGen.KillTile(x, tilePosition.Y, false, false, true);
                if (!Framing.GetTileSafely(x, tilePosition.Y + 1).HasTile)
                    WorldGen.PlaceTile(x, tilePosition.Y + 1, TileID.Dirt, true, true);
            }

            bool placed = WorldGen.PlaceObject(
                tilePosition.X,
                tilePosition.Y,
                type,
                true);

            if (!placed)
                ModContent.GetInstance<global::ShatteredGenesis.ShatteredGenesis>().Logger.Warn(
                    $"[SG] CampLog 4x1 не встал в {tilePosition}.");

            if (Main.netMode == NetmodeID.Server)
                NetMessage.SendTileSquare(-1, tilePosition.X, tilePosition.Y, 6);
        }

        static void RemoveVanillaGuides()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            for (int i = 0; i < Main.maxNPCs; i++)
            {
                NPC npc = Main.npc[i];
                if (npc.active && npc.type == NPCID.Guide)
                {
                    npc.active = false;
                    npc.netUpdate = true;
                }
            }
        }

        // Последняя страховка поверх spawn pool: если враг уже существовал
        // к моменту старта сцены или был создан сторонним событием, удаляем его.
        public override void PreUpdateEntities()
        {
            if (SuppressHostileSpawns)
                PurgeHostileNPCs();
        }

        static void PurgeHostileNPCs()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            for (int i = 0; i < Main.maxNPCs; i++)
            {
                NPC npc = Main.npc[i];
                if (!npc.active || npc.friendly)
                    continue;

                npc.active = false;
                if (Main.netMode == NetmodeID.Server)
                    NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, i);
            }
        }

        static void SpawnStoryteller()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            storytellerIndex = NPC.NewNPC(
                new EntitySource_Misc("ShatteredGenesis:CampfireScene"),
                (int)storytellerAnchor.X,
                (int)storytellerAnchor.Y,
                ModContent.NPCType<StoryTeller>());

            if (TryGetStoryteller(out NPC storyteller))
            {
                storyteller.Bottom = storytellerAnchor;
                storyteller.netUpdate = true;
            }
        }

        // -------------------- блокировка игрока --------------------

        public static void ApplyPlayerLock(Player player)
        {
            if (!PlayerLocked || player == null || !player.active)
                return;

            // Инвентарь закрывается не только визуально: его горячая клавиша
            // и release-state глушатся до обработки ванильных слотов.
            Main.playerInventory = false;
            player.releaseInventory = false;
            player.controlInv = false;

            player.controlLeft = false;
            player.controlRight = false;
            player.controlJump = false;
            player.controlUp = false;
            player.controlDown = false;
            player.controlHook = false;
            player.controlMount = false;
            player.controlUseItem = false;
            player.controlUseTile = false;
            player.controlThrow = false;
            player.velocity = Vector2.Zero;
            player.fallStart = (int)(player.position.Y / 16f);
            player.direction = 1;

            if (WorldGen.InWorld(playerSeatTile.X, playerSeatTile.Y, 10))
            {
                Tile seat = Framing.GetTileSafely(playerSeatTile.X, playerSeatTile.Y);
                if (seat.HasTile && seat.TileType == ModContent.TileType<CampLog>())
                {
                    if (!player.sitting.isSitting)
                        player.sitting.SitDown(player, playerSeatTile.X, playerSeatTile.Y);
                    return;
                }
            }

            // Аварийная страховка: даже если другой мод уничтожил бревно,
            // персонаж не телепортируется прочь из мизансцены.
            player.Bottom = playerSeatWorld;
        }

        // -------------------- взаимодействие и диалог --------------------

        static void UpdateInteraction()
        {
            bool right = Main.mouseRight;
            bool pressed = right && !previousRight;
            previousRight = right;
            previousLeft = Main.mouseLeft;

            if (!TryGetStoryteller(out NPC storyteller))
                return;

            bool hovered = storyteller.Hitbox.Contains(Main.MouseWorld.ToPoint());
            if (hovered)
            {
                Main.LocalPlayer.mouseInterface = true;
                Main.LocalPlayer.noThrow = 2;
                Main.cursorOverride = CursorOverrideID.DefaultCursor;
            }

            if (pressed && hovered)
                StartDialogue();
        }

        static void StartDialogue()
        {
            if (phase != ScenePhase.AwaitingInteraction)
                return;

            // Команда повтора может войти сюда после частичной перезагрузки
            // статики. Тексты обязаны существовать до первого кадра букв.
            BuildNodes();

            phase = ScenePhase.Dialogue;
            hideUiCaptured = true;
            previousHideUi = Main.hideUI;
            // hideUI обязан быть false: при true Terraria не вызывает
            // интерфейсный конвейер и физически не рисует нативный курсор.
            Main.hideUI = false;
            Main.playerInventory = false;
            Main.drawingPlayerChat = false;
            Main.mapFullscreen = false;

            // Курсор остаётся полностью системным. Мы лишь возвращаем ему
            // нормальный масштаб/стиль и отдаём мышь интерфейсному каналу.
            Main.LocalPlayer.mouseInterface = true;
            Main.cursorScale = 1f;
            Main.cursorOverride = CursorOverrideID.DefaultCursor;

            choiceRects = Array.Empty<Rectangle>();
            EnterNode(0);
        }

        static void HandleDialogueInput()
        {
            bool left = Main.mouseLeft;
            bool pressed = left && !previousLeft;
            previousLeft = left;
            previousRight = Main.mouseRight;

            clickCooldown = Math.Max(0, clickCooldown - 1);
            if (!pressed || clickCooldown > 0 || nodeIndex < 0)
                return;

            DNode node = nodes[nodeIndex];
            if (node.Choices != null)
                return; // ответы кликаются в DrawDialoguePanel по точному rect

            EnterNode(node.Next[0]);
        }

        static void EnterNode(int index)
        {
            if (index < 0)
            {
                BeginFinale();
                return;
            }

            nodeIndex = index;
            clickCooldown = 10;
            choiceRects = Array.Empty<Rectangle>();

            if (index == GuideArrivalNode && guideIndex < 0)
                SpawnGuideFromDarkness();
        }

        static void BeginFinale()
        {
            nodeIndex = -1;
            phase = ScenePhase.StorytellerLeaving;
            finalTicks = 0;
            choiceRects = Array.Empty<Rectangle>();
        }

        // -------------------- Гид --------------------

        static void SpawnGuideFromDarkness()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            // 0.8 ширины экрана от центра игрока гарантированно находится
            // за видимой кромкой камеры. По умолчанию Гид приходит справа;
            // у границы мира направление безопасно переворачивается.
            float offset = Main.screenWidth * 0.8f;
            float startWorldX = Main.LocalPlayer.Center.X + offset;
            if (startWorldX >= (Main.maxTilesX - 30) * 16f)
                startWorldX = Main.LocalPlayer.Center.X - offset;

            int startTileX = Math.Clamp((int)(startWorldX / 16f),
                20, Main.maxTilesX - 20);
            int startGroundY = FindSurfaceY(startTileX);
            guideApproachStart = new Vector2(
                startTileX * 16f + 8f,
                startGroundY * 16f);

            guideIndex = NPC.NewNPC(
                new EntitySource_Misc("ShatteredGenesis:GuideArrival"),
                (int)guideApproachStart.X,
                (int)guideApproachStart.Y,
                NPCID.Guide);

            if (TryGetGuide(out NPC guide))
            {
                guide.Bottom = guideApproachStart;
                guide.alpha = 0;
                guide.noGravity = false;
                guide.noTileCollide = false;
                guide.dontTakeDamage = true;
                guide.aiStyle = -1; // движение контролирует сцена, физику — Terraria
                guide.velocity = Vector2.Zero;
                guide.direction = Math.Sign(campfireWorld.X - guide.Center.X);
                guide.spriteDirection = guide.direction;
                guide.netUpdate = true;
            }

            guideArrivalTicks = 0;
            guideSeated = false;
        }

        static void UpdateGuideArrival()
        {
            if (!TryGetGuide(out NPC guide))
                return;

            if (guideSeated)
            {
                KeepGuideSeated(guide);
                return;
            }

            guideArrivalTicks++;
            guide.SitDown(guideSeatTile, out _, out Vector2 seatBottom);
            float horizontalDistance = seatBottom.X - guide.Bottom.X;
            int walkDirection = Math.Sign(horizontalDistance);

            if (Math.Abs(horizontalDistance) <= 9f || guideArrivalTicks > 1800)
            {
                guideSeated = true;
                KeepGuideSeated(guide);
                return;
            }

            // Никаких Lerp-перелётов и noTileCollide: обычная скорость,
            // гравитация и столкновения NPC. На препятствии — маленький шаг.
            float speedInterpolant = MathHelper.Clamp(
                Math.Abs(horizontalDistance) / 420f, 0f, 1f);
            float walkSpeed = MathHelper.Lerp(0.65f, 1.55f, speedInterpolant);

            guide.aiStyle = -1;
            guide.alpha = 0;
            guide.noGravity = false;
            guide.noTileCollide = false;
            guide.dontTakeDamage = true;
            guide.direction = walkDirection;
            guide.spriteDirection = walkDirection;
            guide.velocity.X = walkDirection * walkSpeed;

            if (guide.collideX && guide.velocity.Y == 0f)
                guide.velocity.Y = -3.4f;
        }

        static void KeepGuideSeated(NPC guide)
        {
            guide.noGravity = false;
            guide.noTileCollide = false;
            guide.dontTakeDamage = false;
            guide.aiStyle = NPCAIStyleID.Passive; // стандартный ИИ городского NPC
            guide.SitDown(guideSeatTile, out int direction, out Vector2 bottom);
            guide.Bottom = bottom;
            guide.direction = direction;
            guide.spriteDirection = direction;
            guide.velocity = Vector2.Zero;
            guide.ai[0] = 5f; // ванильное состояние «сидит»
            guide.ai[1] = 900f;
        }

        static bool TryGetGuide(out NPC guide)
        {
            guide = null;
            if (guideIndex < 0 || guideIndex >= Main.maxNPCs)
                return false;

            NPC candidate = Main.npc[guideIndex];
            if (!candidate.active || candidate.type != NPCID.Guide)
                return false;

            guide = candidate;
            return true;
        }

        // -------------------- Сказитель --------------------

        public static bool TryGetStoryteller(out NPC storyteller)
        {
            storyteller = null;
            if (storytellerIndex < 0 || storytellerIndex >= Main.maxNPCs)
                return false;

            NPC candidate = Main.npc[storytellerIndex];
            if (!candidate.active || candidate.type != ModContent.NPCType<StoryTeller>())
                return false;

            storyteller = candidate;
            return true;
        }

        public static void NotifyStorytellerGone()
        {
            storytellerGone = true;
            storytellerIndex = -1;
        }

        // -------------------- главный тик --------------------

        public override void PostUpdateEverything()
        {
            if (!Active)
                return;

            if (!SubworldSystem.IsActive<DecoyWorld>())
            {
                ResetScene(false);
                return;
            }

            // С момента StartDialogue и до CompleteScene/ResetScene интерфейс
            // обязан оставаться включённым. Это единственная запись hideUI
            // в главном тике; фазовые ветки больше не спорят друг с другом.
            if (hideUiCaptured)
                Main.hideUI = false;

            bool running = phase != ScenePhase.Inactive;
            if (running)
            {
                Main.dayTime = false;
                Main.time = 16200;
                Main.raining = false;
                Main.windSpeedCurrent = 0f;
                Main.windSpeedTarget = 0f;
            }

            if (PlayerLocked)
                ApplyPlayerLock(Main.LocalPlayer);

            // Фокус интерфейса ставится ДО обработки ЛКМ, чтобы клик не
            // успевал провалиться в мир или выбранный предмет.
            if (phase == ScenePhase.Dialogue)
            {
                Main.LocalPlayer.mouseInterface = true;
                Main.LocalPlayer.noThrow = 2;
                Main.hideUI = false;
                Main.playerInventory = false;
                Main.drawingPlayerChat = false;
                Main.mapFullscreen = false;
                Main.cursorScale = 1f;
                Main.cursorOverride = CursorOverrideID.DefaultCursor;
            }

            if (phase == ScenePhase.AwaitingInteraction)
                UpdateInteraction();
            else if (phase == ScenePhase.Dialogue)
                HandleDialogueInput();
            else
            {
                previousLeft = Main.mouseLeft;
                previousRight = Main.mouseRight;
            }

            if (guideIndex >= 0)
                UpdateGuideArrival();

            if (phase == ScenePhase.StorytellerLeaving)
            {
                finalTicks++;
                if (!TryGetStoryteller(out _) && finalTicks > 30)
                    storytellerGone = true;

                if (storytellerGone && guideSeated && finalTicks > 90)
                    BeginWakeFadeOut();
            }
            else if (phase == ScenePhase.WakeFadeOut)
            {
                finalTicks++;
                sleepFade = MathHelper.Clamp(finalTicks / 170f, 0f, 1f);
                ActivateSleepFade();

                if (sleepFade >= 1f && finalTicks >= 190)
                    WakePlayerAtCamp();
            }
            else if (phase == ScenePhase.WakeFadeIn)
            {
                finalTicks++;
                sleepFade = 1f - MathHelper.Clamp(finalTicks / 120f, 0f, 1f);
                ActivateSleepFade();

                if (sleepFade <= 0f && finalTicks >= 120)
                    phase = ScenePhase.Inactive;
            }

            float zoomTarget = phase switch
            {
                ScenePhase.Dialogue => 1.62f,
                ScenePhase.StorytellerLeaving => 1.55f,
                ScenePhase.WakeFadeOut => 1.15f,
                ScenePhase.WakeFadeIn => 1f,
                _ => 1f
            };
            cinematicZoom = MathHelper.Lerp(cinematicZoom, zoomTarget, 0.055f);
            if (Math.Abs(cinematicZoom - zoomTarget) < 0.001f)
                cinematicZoom = zoomTarget;

            float fireTarget = phase switch
            {
                ScenePhase.StorytellerLeaving => 0.35f,
                ScenePhase.WakeFadeOut => 0.08f,
                ScenePhase.WakeFadeIn => 0.18f,
                _ => 1f
            };
            fireStrength = MathHelper.Lerp(fireStrength, fireTarget, 0.045f);

            UpdateCampLightAndParticles();
            if (running)
                ActivateCampDarkness();
        }

        static void BeginWakeFadeOut()
        {
            phase = ScenePhase.WakeFadeOut;
            finalTicks = 0;
            sleepFade = 0f;
            choiceRects = Array.Empty<Rectangle>();
        }

        static void ActivateSleepFade()
        {
            var filter = StoryDirector.SleepFilter;
            if (filter == null)
                return;

            try
            {
                filter.TrySetParameter("vignetteAspect",
                    Main.screenWidth / (float)Math.Max(1, Main.screenHeight));
                filter.TrySetParameter("sceneFade", sleepFade);
                filter.Activate();
            }
            catch (Exception)
            {
                // Переход не должен зависеть от видеокарты: при отказе
                // фильтра таймер всё равно завершит событие.
            }
        }

        static void WakePlayerAtCamp()
        {
            if (wakeTriggered)
                return;

            wakeTriggered = true;
            phase = ScenePhase.WakeFadeIn;
            finalTicks = 0;
            WorldFlagsSystem.IntroDone = true;
            RestoreNativeUi();

            Player player = Main.LocalPlayer;
            player.sitting.SitUp(player);
            player.velocity = Vector2.Zero;
            player.releaseInventory = true;
            player.controlInv = false;
        }

        static void ResetScene(bool deactivateActors)
        {
            RestoreNativeUi();

            if (deactivateActors && TryGetStoryteller(out NPC storyteller))
                storyteller.active = false;

            phase = ScenePhase.Inactive;
            nodeIndex = -1;
            storytellerIndex = -1;
            guideIndex = -1;
            storytellerGone = false;
            guideSeated = false;
            cinematicZoom = 1f;
            fireStrength = 0f;
            sleepFade = 0f;
            wakeTriggered = false;
            choiceRects = Array.Empty<Rectangle>();
        }

        static void RestoreNativeUi()
        {
            if (hideUiCaptured)
                Main.hideUI = previousHideUi;
            else if (phase != ScenePhase.Inactive)
                Main.hideUI = false;

            hideUiCaptured = false;
        }

        // -------------------- свет и фильтр --------------------

        static void UpdateCampLightAndParticles()
        {
            if (fireStrength <= 0.001f)
                return;

            float flicker = 0.90f + 0.10f * (float)Math.Sin(Main.GameUpdateCount * 0.19f);
            Vector2 flameWorld = CampfireFlameWorld;
            Lighting.AddLight(flameWorld,
                new Vector3(1.20f, 0.42f, 0.08f) * fireStrength * flicker);

            if (phase != ScenePhase.WakeFadeIn && Main.GameUpdateCount % 4 == 0)
            {
                try
                {
                    new EmberParticle(
                        flameWorld + new Vector2(Main.rand.NextFloat(-10f, 10f), -5f),
                        new Vector2(Main.rand.NextFloat(-0.22f, 0.22f),
                            Main.rand.NextFloat(-1.35f, -0.55f)),
                        new Color(255, 222, 126),
                        90 + Main.rand.Next(90)).Spawn();
                }
                catch (Exception)
                {
                    // Частицы декоративны и не имеют права прервать сцену.
                }
            }
        }

        static void ActivateCampDarkness()
        {
            if (darknessFilterDead)
                return;

            var filter = StoryDirector.CampDarknessFilter;
            if (filter == null)
                return;

            try
            {
                Vector2 lightOnScreen = CampfireFlameWorld - Main.screenPosition;
                Vector2 uv = new Vector2(
                    MathHelper.Clamp(lightOnScreen.X / Math.Max(1f, Main.screenWidth), 0f, 1f),
                    MathHelper.Clamp(lightOnScreen.Y / Math.Max(1f, Main.screenHeight), 0f, 1f));

                float radius = 0.18f + 0.13f * fireStrength;
                filter.TrySetParameter("lightCenter", uv);
                filter.TrySetParameter("lightRadius", radius);
                filter.TrySetParameter("darkness", 1f);
                filter.Activate();
            }
            catch (Exception)
            {
                darknessFilterDead = true;
            }
        }

        // -------------------- кинематографичная камера --------------------

        public override void ModifyScreenPosition()
        {
            if (!Active || cinematicZoom <= 1.002f)
                return;

            Player player = Main.LocalPlayer;
            Vector2 second = campfireWorld;
            if (TryGetStoryteller(out NPC storyteller))
                second = storyteller.Center;
            else if (TryGetGuide(out NPC guide))
                second = guide.Center;

            Vector2 focus = (player.Center + second) * 0.5f;
            Vector2 desired = focus - new Vector2(Main.screenWidth, Main.screenHeight) * 0.5f;
            Main.screenPosition = Vector2.Lerp(Main.screenPosition, desired, 0.12f);
        }

        public override void ModifyTransformMatrix(ref SpriteViewMatrix Transform)
        {
            if (!Active || cinematicZoom <= 1.002f)
                return;

            // GameViewMatrix и есть матрица, переданная сюда движком.
            // Умножаем её штатный пользовательский zoom, а не заменяем его.
            Transform.Zoom = Transform.Zoom * cinematicZoom;
        }

        // Костёр и брёвна теперь являются настоящими ModTile-объектами.
        // Отдельного PostDrawTiles и плоских world-space картинок больше нет.

        static void LoadSceneArt()
        {
            if (sceneArtTried)
                return;
            sceneArtTried = true;

            slideArt = new Texture2D[StoryDirector.SlideCount];
            slideFrames = new Texture2D[StoryDirector.SlideCount][];
            for (int i = 0; i < StoryDirector.SlideCount; i++)
            {
                List<Texture2D> frames = new List<Texture2D>();
                for (int f = 0; f < 16; f++)
                {
                    Texture2D frame = RequestTexture(
                        $"Assets/Slides/Slide{i + 1}_{f}.png",
                        $"ShatteredGenesis/Assets/Slides/Slide{i + 1}_{f}");
                    if (frame == null)
                        break;
                    frames.Add(frame);
                }

                if (frames.Count > 1)
                    slideFrames[i] = frames.ToArray();
                else
                    slideArt[i] = RequestTexture(
                        $"Assets/Slides/Slide{i + 1}.png",
                        $"ShatteredGenesis/Assets/Slides/Slide{i + 1}");
            }
        }

        static Texture2D RequestTexture(string relativeWithExtension, string assetPath)
        {
            if (!ModAssetHelper.Exists(relativeWithExtension))
                return null;
            try
            {
                return ModContent.Request<Texture2D>(assetPath,
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                return null;
            }
        }

        // -------------------- UI поверх скрытого интерфейса Terraria --------------------

        public override void ModifyInterfaceLayers(List<GameInterfaceLayer> layers)
        {
            bool cinematicHudHidden = hideUiCaptured && PlayerLocked;
            if (!cinematicHudHidden || Main.dedServ)
                return;

            // Список слоёв не меняем: tModLoader использует его порядок
            // и для ввода. Выключаем только флаг Draw у конкретных HUD-слоёв.
            foreach (GameInterfaceLayer layer in layers)
            {
                switch (layer.Name)
                {
                    case "Vanilla: Entity Health Bars":
                    case "Vanilla: Invasion Progress Bars":
                    case "Vanilla: Map / Minimap":
                    case "Vanilla: Resource Bars":
                    case "Vanilla: Inventory":
                    case "Vanilla: Info Accessories Bar":
                    case "Vanilla: Settings Button":
                    case "Vanilla: Hotbar":
                    case "Vanilla: Builder Accessories Bar":
                    case "Vanilla: Radial Hotbars":
                    case "Vanilla: Player Chat":
                        layer.Active = false;
                        break;
                }
            }

            if (phase != ScenePhase.Dialogue || nodeIndex < 0)
                return;

            int mouseTextIndex = layers.FindIndex(layer =>
                layer.Name.Equals("Vanilla: Mouse Text", StringComparison.Ordinal));
            if (mouseTextIndex < 0)
                mouseTextIndex = layers.Count;

            // Рисуем панель в штатном UI-проходе перед Mouse Text/курсором.
            // SpriteBatch уже открыт движком с правильной UIScaleMatrix.
            layers.Insert(mouseTextIndex, new LegacyGameInterfaceLayer(
                "ShatteredGenesis: Campfire Dialogue",
                () =>
                {
                    // Фокус ставится внутри самого UI-прохода, до панели
                    // и до ванильного слоя курсора.
                    Main.LocalPlayer.mouseInterface = true;
                    DrawCinematicInterface(Main.spriteBatch);
                    return true;
                },
                InterfaceScaleType.None));
        }

        static void DrawCinematicInterface(SpriteBatch spriteBatch)
        {
            Viewport viewport = Main.instance.GraphicsDevice.Viewport;
            float screenWidth = viewport.Width;
            float screenHeight = viewport.Height;

            DrawVision(spriteBatch, screenWidth, screenHeight);
            DrawDialoguePanel(spriteBatch);
        }

        static Point GetUiMousePoint()
        {
            return Main.MouseScreen.ToPoint();
        }

        static void DrawVision(SpriteBatch sb, float screenWidth, float screenHeight)
        {
            if (nodeIndex < 0 || nodes[nodeIndex].Slide < 0)
                return;

            int slide = nodes[nodeIndex].Slide;
            Texture2D texture = null;
            if (slideFrames != null && slide < slideFrames.Length
                && slideFrames[slide] != null && slideFrames[slide].Length > 0)
            {
                Texture2D[] frames = slideFrames[slide];
                texture = frames[(int)(Main.GameUpdateCount / 38 % (uint)frames.Length)];
            }
            else if (slideArt != null && slide < slideArt.Length)
            {
                texture = slideArt[slide];
            }

            if (texture == null)
                return;

            float maxWidth = screenWidth * 0.56f;
            float maxHeight = screenHeight * 0.48f;
            float scale = Math.Min(maxWidth / texture.Width, maxHeight / texture.Height);
            Vector2 size = new Vector2(texture.Width, texture.Height) * scale;
            Rectangle destination = new Rectangle(
                (int)(screenWidth * 0.5f - size.X * 0.5f),
                (int)(screenHeight * 0.08f),
                (int)size.X, (int)size.Y);

            sb.Draw(TextureAssets.MagicPixel.Value,
                new Rectangle(destination.X - 3, destination.Y - 3,
                    destination.Width + 6, destination.Height + 6),
                new Color(124, 79, 18) * 0.55f);
            sb.Draw(texture, destination, Color.White * 0.58f);
        }

        static void DrawDialoguePanel(SpriteBatch sb)
        {
            Main.LocalPlayer.mouseInterface = true;
            Main.cursorScale = 1f;

            Viewport viewport = Main.instance.GraphicsDevice.Viewport;
            float screenWidth = viewport.Width;
            float screenHeight = viewport.Height;
            float interfaceScale = MathHelper.Clamp(
                Main.UIScale * screenWidth / 1920f, 0.75f, 1.55f);

            DNode node = nodes[nodeIndex];
            DynamicSpriteFont font = FontAssets.MouseText.Value;
            Texture2D pixel = TextureAssets.MagicPixel.Value;
            Point mouse = GetUiMousePoint();

            float textScale = 1.12f * interfaceScale;
            int margin = (int)(20f * interfaceScale);
            int panelWidth = Math.Max(300, (int)(screenWidth * 0.78f));
            panelWidth = Math.Min(panelWidth, Math.Max(300, (int)screenWidth - 24));
            float usableWidth = panelWidth - margin * 2f;

            Vector2 textScaleVector = new Vector2(textScale);
            Vector2 textBounds = ChatManager.GetStringSize(
                font, node.Text, textScaleVector, usableWidth);

            int nameHeight = (int)(font.LineSpacing * 1.10f * interfaceScale) + 5;
            int buttonStep = (int)(40f * interfaceScale);
            int buttonHeight = (int)(32f * interfaceScale);
            int choicesHeight = node.Choices != null
                ? node.Choices.Length * buttonStep + (int)(10f * interfaceScale)
                : (int)(32f * interfaceScale);
            int panelHeight = margin + nameHeight + (int)Math.Ceiling(textBounds.Y)
                + choicesHeight + margin;

            int panelX = (int)((screenWidth - panelWidth) * 0.5f);
            int panelY = Math.Max(12,
                (int)Math.Floor(screenHeight - panelHeight - 18f));

            Rectangle border = new Rectangle(
                panelX - 3, panelY - 3, panelWidth + 6, panelHeight + 6);
            Rectangle body = new Rectangle(panelX, panelY, panelWidth, panelHeight);
            sb.Draw(pixel, border, new Color(134, 86, 18) * 0.92f);
            sb.Draw(pixel, body, new Color(4, 3, 2) * 0.96f);

            sb.DrawString(font, "Сказитель",
                new Vector2(panelX + margin, panelY + margin),
                new Color(255, 190, 55), 0f, Vector2.Zero,
                1.12f * interfaceScale, SpriteEffects.None, 0f);

            Vector2 textPosition = new Vector2(
                panelX + margin, panelY + margin + nameHeight);
            ChatManager.DrawColorCodedStringWithShadow(sb, font, node.Text,
                textPosition, Color.White, 0f, Vector2.Zero,
                textScaleVector, usableWidth);

            float buttonY = textPosition.Y + textBounds.Y + 9f;
            if (node.Choices != null)
            {
                List<Rectangle> rects = new List<Rectangle>();
                for (int i = 0; i < node.Choices.Length; i++)
                {
                    string label = node.Choices[i];
                    Vector2 measured = font.MeasureString(label) * 1.08f * interfaceScale;
                    int buttonWidth = Math.Min((int)usableWidth,
                        (int)measured.X + (int)(28f * interfaceScale));
                    Rectangle rect = new Rectangle(
                        panelX + margin, (int)buttonY, buttonWidth, buttonHeight);
                    rects.Add(rect);

                    bool hovered = rect.Contains(mouse);
                    if (hovered)
                    {
                        // Точно как в SolynDialogUIManager: фокус и блок мира
                        // ставятся только на реально наведённом ответе.
                        Main.LocalPlayer.mouseInterface = true;
                        Main.blockMouse = true;

                        if (Main.mouseLeft && Main.mouseLeftRelease
                            && clickCooldown <= 0)
                        {
                            EnterNode(node.Next[i]);
                            return;
                        }
                    }

                    sb.Draw(pixel, rect, hovered
                        ? new Color(132, 84, 18)
                        : new Color(58, 39, 10) * 0.95f);
                    sb.DrawString(font, label,
                        new Vector2(rect.X + 14, rect.Y + 7),
                        hovered ? new Color(255, 242, 199) : new Color(255, 190, 55),
                        0f, Vector2.Zero, 1.08f * interfaceScale,
                        SpriteEffects.None, 0f);
                    buttonY += buttonStep;
                }
                choiceRects = rects.ToArray();
            }
            else
            {
                const string hint = "ЛКМ — продолжить";
                Vector2 hintSize = font.MeasureString(hint);
                sb.DrawString(font, hint,
                    new Vector2(panelX + panelWidth - margin - hintSize.X,
                        buttonY + 4f),
                    new Color(158, 137, 96), 0f, Vector2.Zero,
                    1f, SpriteEffects.None, 0f);
                choiceRects = Array.Empty<Rectangle>();
            }
        }

        // -------------------- 27 узлов исходного сказа --------------------

        static void BuildNodes()
        {
            if (nodes != null)
                return;

            DNode N(string text, string[] choices, int[] next, int slide = -1)
                => new DNode { Text = text, Choices = choices, Next = next, Slide = slide };

            nodes = new DNode[]
            {
                /* 0*/ N("Слушай… ты вообще веришь в сказки? Ну, в эти древние байки, которые безумцы на заборах царапают?",
                    new[] { "Верю.", "Не верю.", "Смотря каким." }, new[] { 1, 2, 3 }),
                /* 1*/ N("Ха. Тогда тебе будет легче слушать. Садись поближе — расскажу, время всё равно есть.", null, new[] { 4 }),
                /* 2*/ N("Я вот тоже не верил. Пока сам не увидел то, о чём живые вообще говорить боятся. Садись поближе.", null, new[] { 4 }),
                /* 3*/ N("Разумный ответ. Но в этой сказке старейшины клялись, что всё — правда. Садись, слушай.", null, new[] { 4 }),
                /* 4*/ N("Давным-давно, чёрт знает когда, из абсолютного ниоткуда пришли Они. Семеро. Исполины: рваные плащи из чистой тени, рогатые венцы, а вместо лиц — пустота. И самое дурацкое: им места нигде не хватало. Бесконечность вокруг — а им, видишь ли, тесно!", null, new[] { 5 }, 0),
                /* 5*/ N("Ну и они выковали себе место. Свой собственный мир — самый первый, настоящий. Чистый свет, как чистый холст для художника. Сидели в тишине, кайфовали. Безмятежность полная.", null, new[] { 6 }, 1),
                /* 6*/ N("Красиво, да?", new[] { "Красиво…", "Скучновато звучит." }, new[] { 7, 8 }),
                /* 7*/ N("То-то. Красота — она долго не живёт.", null, new[] { 9 }),
                /* 8*/ N("Скука тоже долго не живёт. От бесцельной тишины и тыкают куда не надо.", null, new[] { 9 }),
                /* 9*/ N("Один из них — Ваэлор его звали, Сеятель типа — решил поумничать: пальцем по холсту проведу, по приколу. Коснулся. Обычное, блин, касание пальца — а мир возьми, да и отзовись!", null, new[] { 10 }, 2),
                /*10*/ N("Ты бы на его месте тыкнул?", new[] { "Тыкнул бы.", "Ни за что.", "Пальцем?! Серьёзно?" }, new[] { 11, 12, 13 }),
                /*11*/ N("Вот! Все бы тыкнули. Потому и рассказывать есть что.", null, new[] { 14 }),
                /*12*/ N("Так говорят все. Сидя у тёплого костра — все у нас осторожные.", null, new[] { 14 }),
                /*13*/ N("Пальцем, пальцем. У таких, как они, палец — что молот судьбы.", null, new[] { 14 }),
                /*14*/ N("Из того касания вылезло то, что назвалось жизнью. Настоящей: меняется, дышит, дохнет… И любила она себя больше всего на свете. На создателей своих ей было абсолютно плевать.", null, new[] { 15 }, 3),
                /*15*/ N("Чтобы расти, эта тварь начала жрать свет, на котором мир держался. Жрала, пока нити не лопнули — хрусь! — мир пополам, от неба до костей земных. А вырвавшийся свет взбесился и начал забирать самих создателей: один растворился… второй… третий… Остался один-единственный. Последний Архон.", null, new[] { 16 }, 4),
                /*16*/ N("Страшно слушать такое?", new[] { "Да.", "Нет.", "Я бы убежал давно." }, new[] { 17, 18, 19 }),
                /*17*/ N("И правильно делаешь. Тогда страшно было даже небу.", null, new[] { 20 }),
                /*18*/ N("Смелый. Ну-ну. Посмотрим, что скажешь у границы мира.", null, new[] { 20 }),
                /*19*/ N("Бежали все. Да бежать было некуда: кругом — их же собственный свет.", null, new[] { 20 }),
                /*20*/ N("Последний оказался поумнее братьев: собрал остатки безумного света и укрыл им рану мира — стеной от края до края. Чтобы никто и никогда эту рану не нашёл.", null, new[] { 21 }, 5),
                /*21*/ N("А перед тем, как самому исчезнуть, он оставил в свете пророчество: мол, придёт чудак, который вообще не боится смерти. Ни черта не боится! И соткан он будет из тех же нитей света.", null, new[] { 22 }, 6),
                /*22*/ N("Веришь в пророчества?", new[] { "Верю.", "Нет.", "В хорошие — верю." }, new[] { 23, 24, 25 }),
                /*23*/ N("Тогда слушай внимательно, это про тебя может быть.", null, new[] { 26 }),
                /*24*/ N("Я тоже не очень. Но эта штука… живая. Проверено.", null, new[] { 26 }),
                /*25*/ N("А плохих пророчеств и нет. Смотря для кого.", null, new[] { 26 }),
                /*26*/ N("Этот Последний… он до сих пор ищет. Неугасающую душу, которая не кончается, сколько её ни убивай. …Слушай… а может, это он тебя ищет, а? Ну ладно, чё ты вылупился — спи давай. Завтра день тяжёлый.", null, new[] { -1 }, 7),
            };
        }
    }

    // Натуральный spawn блокируется штатными GlobalNPC-хуками. Мы не трогаем
    // Main.maxNetNPCs: это глобальная ёмкость NPC, её изменение сломало бы
    // самого Сказителя и динамическое появление Гида.
    public sealed class CampfireSpawnSuppression : GlobalNPC
    {
        public override void EditSpawnRate(Player player,
            ref int spawnRate, ref int maxSpawns)
        {
            if (!CampfireScene.SuppressHostileSpawns)
                return;

            spawnRate = int.MaxValue;
            maxSpawns = 0;
        }

        public override void EditSpawnPool(
            IDictionary<int, float> pool, NPCSpawnInfo spawnInfo)
        {
            if (CampfireScene.SuppressHostileSpawns)
                pool.Clear();
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/FirstWorldGenSystem.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using SubworldLibrary;
using Terraria;
using Terraria.GameContent.Generation;
using Terraria.ID;
using Terraria.IO;
using Terraria.ModLoader;
using Terraria.WorldBuilding;

// FirstWorldGenSystem — система генерации Первомира.
// Версия M1.5 «Пепел и трещины, черновик №1».
// Генерация разбита на этапы (passes) — они видны на экране создания мира,
// поэтому подписаны лоровыми фразами.
namespace ShatteredGenesis.Common.Systems
{
    public class FirstWorldGenSystem : ModSystem
    {
        public override void ModifyWorldGenTasks(List<GenPass> tasks, ref double totalWeight)
        {
            // ЭКСПЕРИМЕНТ: если генерация идёт ВНУТРИ под-мира — не лезем,
            // там нужен чистый ванильный список.
            if (SubworldSystem.AnyActive())
                return;

            // ЭКСПЕРИМЕНТ 0.3.4.4: хватаем слепок ванильного конвейера ВМЕСТЕ
            // с чертежом (GenVars.configuration уже наполнен движком к этому
            // моменту). Порядок критичен: слепок ПЕРЕД расчисткой.
            VanillaGenSnapshot.Capture(tasks, GenVars.configuration);

            tasks.Clear(); // ванили нет. Совсем.

            // ЭТАП 0.4 «Занавес»: подписи проходов ПЕРЕОДЕТЫ под ваниль.
            // Игрок видит обычный экран создания мира — и не должен догадаться,
            // что генерируется Первомир. (Код генерации не изменён ни на байт.)
            tasks.Add(new PassLegacy("Создание ландшафта…", GenerateBase, 300f));
            tasks.Add(new PassLegacy("Генерация пещер…", GenerateCaves, 100f));
            tasks.Add(new PassLegacy("Размещение рудных жил…", GenerateSeams, 60f));
            tasks.Add(new PassLegacy("Завершающие штрихи…", GenerateCrater, 20f));
        }

        // ---------------- ЭТАП 1. Террейн ----------------
        private void GenerateBase(GenerationProgress progress, GameConfiguration config)
        {
            double surfaceBase = Main.maxTilesY * 0.35;
            Main.worldSurface = surfaceBase;
            Main.rockLayer = Main.maxTilesY * 0.55;
            GenVars.worldSurface = surfaceBase;
            GenVars.worldSurfaceHigh = surfaceBase;
            GenVars.worldSurfaceLow = surfaceBase + 5;
            GenVars.rockLayerHigh = Main.rockLayer;
            GenVars.rockLayerLow = Main.rockLayer + 5;

            for (int x = 0; x < Main.maxTilesX; x++)
            {
                double wave = Math.Sin(x * 0.012) * 10.0 + Math.Sin(x * 0.045) * 3.0;
                int top = (int)(surfaceBase + wave) + WorldGen.genRand.Next(-1, 2);

                for (int y = top; y < Main.maxTilesY; y++)
                {
                    // Верхняя корка — пепел (пахнет мёртвым миром), ниже — камень.
                    // Это ВРЕМЕННЫЕ ванильные тайлы-заменители; свои спрайты придут позже.
                    ushort tileType = (y < top + 4) ? TileID.Ash : TileID.Stone;
                    WorldGen.PlaceTile(x, y, tileType, true, true);
                }

                if (x % 200 == 0)
                    progress.Set(x / (double)Main.maxTilesX);
            }
            progress.Set(1.0);
        }

        // ---------------- ЭТАП 2. Пещеры ----------------
        private void GenerateCaves(GenerationProgress progress, GameConfiguration config)
        {
            // Количество туннелей масштабируем от площади мира.
            int tunnels = (Main.maxTilesX * Main.maxTilesY) / 9000;

            for (int i = 0; i < tunnels; i++)
            {
                int x = WorldGen.genRand.Next(40, Main.maxTilesX - 40);
                int y = WorldGen.genRand.Next((int)Main.worldSurface + 10, Main.maxTilesY - 60);

                // TileRunner с type=-1 и addTile=false — это экскаватор: выкапывает, а не строит.
                WorldGen.TileRunner(
                    x, y,
                    WorldGen.genRand.NextDouble() * 4.0 + 3.0,   // толщина
                    WorldGen.genRand.Next(20, 80),               // длина «следа»
                    -1, false,
                    WorldGen.genRand.NextDouble() * 6 - 3,
                    WorldGen.genRand.NextDouble() * 6 - 3);

                if (i % 10 == 0)
                    progress.Set(i / (double)tunnels);
            }

            // Несколько шахт-выходов на поверхность, иначе пещеры будут запечатаны.
            for (int i = 0; i < 6; i++)
            {
                int x = WorldGen.genRand.Next(100, Main.maxTilesX - 100);
                int ys = FindSurfaceY(x);
                WorldGen.TileRunner(x, ys, 2.5, 60, -1, false, 0.2, 1.0);
            }
            progress.Set(1.0);
        }

        // ---------------- ЭТАП 3. Тлеющие жилы ----------------
        private void GenerateSeams(GenerationProgress progress, GameConfiguration config)
        {
            // Топазовые самоцветные блоки светятся тёплым янтарным светом —
            // это наши временные «тлеющие швы» в мёртвом камне.
            int veins = Main.maxTilesX / 25;

            for (int i = 0; i < veins; i++)
            {
                int x = WorldGen.genRand.Next(30, Main.maxTilesX - 30);
                int y = WorldGen.genRand.Next((int)Main.worldSurface + 15, Main.maxTilesY - 50);
                int length = WorldGen.genRand.Next(4, 15);

                for (int k = 0; k < length; k++)
                {
                    WorldGen.PlaceTile(x, y, TileID.TopazGemspark, true, true);
                    x += WorldGen.genRand.Next(-1, 2); // жила дрожит в стороны
                    y += 1;                            // и в целом ползёт вниз
                    if (x < 5 || x > Main.maxTilesX - 5 || y > Main.maxTilesY - 10)
                        break;
                }
            }
            progress.Set(1.0);
        }

        // ---------------- ЭТАП 4. Кратер и спавн ----------------
        private void GenerateCrater(GenerationProgress progress, GameConfiguration config)
        {
            int craterX = Main.maxTilesX / 2;
            int surfaceY = FindSurfaceY(craterX);
            int craterRadius = 14;

            for (int x = craterX - craterRadius; x <= craterX + craterRadius; x++)
            {
                for (int y = surfaceY - 8; y <= surfaceY + 5; y++)
                {
                    double dist = Math.Sqrt((x - craterX) * (x - craterX) + (y - surfaceY) * (y - surfaceY));
                    if (dist < craterRadius * 0.8)
                        WorldGen.KillTile(x, y, false, false, true);
                }
            }

            Main.spawnTileX = craterX;
            Main.spawnTileY = surfaceY + 3;
            progress.Set(1.0);
        }

        // ---------------- Помощники ----------------
        // Находит верхний твёрдый тайл в колонке x.
        private int FindSurfaceY(int x)
        {
            for (int y = 0; y < Main.maxTilesY; y++)
            {
                if (Main.tile[x, y].HasTile && Main.tileSolid[Main.tile[x, y].TileType])
                    return y;
            }
            return (int)Main.worldSurface;
        }

        // ---------------- Атмосфера: приглушённый пепельный свет ----------------
        // Этот хук движок вызывает каждый кадр, спрашивая «какого цвета солнечный свет?».
        // Мы не ЗАМЕНЯЕМ свет, а тонируем его (умножаем) — так ночь всё равно темнеет.
        public override void ModifySunLightColor(ref Color tileColor, ref Color backgroundColor)
        {
            // В под-мире-обманке свет должен оставаться честно ванильным.
            if (SubworldSystem.AnyActive())
                return;

            tileColor = new Color(
                (int)(tileColor.R * 0.82f),
                (int)(tileColor.G * 0.88f),
                (int)(tileColor.B * 1.00f)); // холоднее, мертвее

            backgroundColor = new Color(
                (int)(backgroundColor.R * 0.55f),
                (int)(backgroundColor.G * 0.60f),
                (int)(backgroundColor.B * 0.85f)); // блёклое грязно-синее небо
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/LightWallEvent.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Utilities;
using SubworldLibrary;
using Terraria;
using Terraria.Audio;
using Terraria.GameContent;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.UI;
using ShatteredGenesis.Content.Particles;
using ShatteredGenesis.Content.Subworlds;

namespace ShatteredGenesis.Common.Systems
{
    // ============================================================
    // «Касание Стены Света» — кульминация жизни внутри Скорлупы.
    //
    // Тайминги при 60 UPS:
    //   CountdownDuration = 3600 — ровно 60 секунд после приближения;
    //   BeamTravelDuration = 24  — быстрый полёт луча;
    //   FlashHoldDuration = 120  — две секунды белого экрана;
    //   FlashFadeDuration = 90   — рассеивание белого;
    //   EyeRevealDuration = 150  — проявление Ока и портала;
    //   VortexPullDuration = 180 — три секунды засасывания.
    // ============================================================
    public sealed class LightWallEvent : ModSystem
    {
        enum EventPhase
        {
            Dormant,
            Countdown,
            BeamTravel,
            FlashHold,
            FlashFade,
            EyeReveal,
            VortexPull,
            Transitioning
        }

        public const int CountdownDuration = 3600;
        public const int BeamTravelDuration = 24;
        public const int FlashHoldDuration = 120;
        public const int FlashFadeDuration = 90;
        public const int EyeRevealDuration = 150;
        public const int VortexPullDuration = 180;
        public const float TriggerDistance = 500f;

        static EventPhase phase;
        static int phaseTimer;
        static int countdownTicksRemaining;
        static float beamProgress;
        static float flashAmount;
        static float eyeOpacity;
        static float vortexStrength;
        static float originalMusicVolume;
        static bool capturedMusicVolume;
        static bool pendingPrimordialWake;
        static SlotId humSlot = SlotId.Invalid;

        static Vector2 beamStart;
        static Vector2 beamEnd;
        static Vector2 eyeWorldCenter;
        static Vector2 portalWorldCenter;

        public static bool Active => phase != EventPhase.Dormant;
        public static bool LocksPlayer => (int)phase >= (int)EventPhase.BeamTravel;
        public static float PlayerVisualScale { get; private set; } = 1f;
        public static float PlayerSilhouetteStrength =>
            phase is EventPhase.FlashHold or EventPhase.FlashFade ? flashAmount : 0f;

        public override void OnWorldLoad()
        {
            if (!SubworldSystem.IsActive<DecoyWorld>())
                ResetRuntime(true);
        }

        public override void OnWorldUnload()
        {
            RestoreMusic();
            if (phase != EventPhase.Transitioning)
                PlayerVisualScale = 1f;
            phase = EventPhase.Dormant;
        }

        public override void Unload()
        {
            RestoreMusic();
            phase = EventPhase.Dormant;
            pendingPrimordialWake = false;
        }

        public static bool ConsumePendingPrimordialWake()
        {
            bool result = pendingPrimordialWake;
            pendingPrimordialWake = false;
            return result;
        }

        public static void RestorePlayerVisualScale()
            => PlayerVisualScale = 1f;

        public static void NotifyWallHit(int i, int j)
        {
            if (Main.dedServ || !SubworldSystem.IsActive<DecoyWorld>())
                return;

            Vector2 impact = new Vector2(i * 16f + 8f, j * 16f + 8f);
            for (int k = 0; k < 12; k++)
            {
                Vector2 velocity = Main.rand.NextVector2Circular(2.8f, 2.8f);
                new LightThreadParticle(impact, velocity, 45 + Main.rand.Next(45)).Spawn();
            }
        }

        public override void PostUpdateEverything()
        {
            if (Main.dedServ || !SubworldSystem.IsActive<DecoyWorld>()
                || WorldFlagsSystem.InfusionDone || WorldFlagsSystem.ShellDestroyed)
            {
                if (phase != EventPhase.Transitioning)
                    ResetRuntime(true);
                return;
            }

            Player player = Main.LocalPlayer;
            float wallWorldX = DecoyWorld.LightWallTileX * 16f + 8f;
            float distanceToWall = Math.Abs(player.Center.X - wallWorldX);

            EmitAmbientWallThreads(player, wallWorldX, distanceToWall);

            // Кирка/оружие не ломают стену, но активное использование возле неё
            // создаёт дополнительный всплеск нитей в точке груди игрока.
            if (distanceToWall < 90f && player.controlUseItem
                && Main.GameUpdateCount % 6 == 0)
            {
                NotifyWallHit(DecoyWorld.LightWallTileX,
                    Math.Clamp((int)(player.Center.Y / 16f), 5, Main.maxTilesY - 6));
            }

            if (phase == EventPhase.Dormant && distanceToWall < TriggerDistance)
                StartCountdown();

            if (phase == EventPhase.Countdown)
                UpdateCountdown(player, wallWorldX);
            else if (phase == EventPhase.BeamTravel)
                UpdateBeamTravel(player);
            else if (phase == EventPhase.FlashHold)
                UpdateFlashHold(player);
            else if (phase == EventPhase.FlashFade)
                UpdateFlashFade(player);
            else if (phase == EventPhase.EyeReveal)
                UpdateEyeReveal(player);
            else if (phase == EventPhase.VortexPull)
                UpdateVortexPull(player);

            if (LocksPlayer)
                LockPlayer(player);
        }

        static void StartCountdown()
        {
            phase = EventPhase.Countdown;
            phaseTimer = 0;
            countdownTicksRemaining = CountdownDuration;
            originalMusicVolume = Main.musicVolume;
            capturedMusicVolume = true;
            StartHum();
        }

        static void UpdateCountdown(Player player, float wallWorldX)
        {
            phaseTimer++;
            countdownTicksRemaining = Math.Max(0, countdownTicksRemaining - 1);

            // Плавное затухание музыки начинается с первого тика контакта.
            Main.musicVolume = MathHelper.Lerp(Main.musicVolume, 0f, 0.0065f);

            if (countdownTicksRemaining > 0)
                return;

            phase = EventPhase.BeamTravel;
            phaseTimer = 0;
            beamProgress = 0f;
            beamStart = new Vector2(wallWorldX, player.MountedCenter.Y);
            beamEnd = player.MountedCenter;
            eyeWorldCenter = player.Center - Vector2.UnitY * 285f;
            portalWorldCenter = player.Center - Vector2.UnitY * 105f;
        }

        static void UpdateBeamTravel(Player player)
        {
            phaseTimer++;
            beamEnd = player.MountedCenter;
            beamProgress = MathHelper.Clamp(phaseTimer / (float)BeamTravelDuration, 0f, 1f);

            if (beamProgress < 1f)
                return;

            phase = EventPhase.FlashHold;
            phaseTimer = 0;
            flashAmount = 1f;
        }

        static void UpdateFlashHold(Player player)
        {
            phaseTimer++;
            flashAmount = 1f;
            ActivateStrikeFilter(player, flashAmount);

            if (phaseTimer < FlashHoldDuration)
                return;

            phase = EventPhase.FlashFade;
            phaseTimer = 0;
        }

        static void UpdateFlashFade(Player player)
        {
            phaseTimer++;
            float progress = MathHelper.Clamp(phaseTimer / (float)FlashFadeDuration, 0f, 1f);
            flashAmount = 1f - progress;
            eyeOpacity = progress;
            ActivateStrikeFilter(player, flashAmount);

            if (progress < 1f)
                return;

            phase = EventPhase.EyeReveal;
            phaseTimer = 0;
        }

        static void UpdateEyeReveal(Player player)
        {
            phaseTimer++;
            float progress = MathHelper.Clamp(phaseTimer / (float)EyeRevealDuration, 0f, 1f);
            eyeOpacity = MathHelper.SmoothStep(0f, 1f, progress);
            vortexStrength = MathHelper.Lerp(vortexStrength, 0.72f, 0.035f);
            ActivateVortexFilter(vortexStrength);

            if (progress < 1f)
                return;

            phase = EventPhase.VortexPull;
            phaseTimer = 0;
        }

        static void UpdateVortexPull(Player player)
        {
            phaseTimer++;
            float progress = MathHelper.Clamp(phaseTimer / (float)VortexPullDuration, 0f, 1f);
            float eased = MathHelper.SmoothStep(0f, 1f, progress);

            vortexStrength = MathHelper.Lerp(vortexStrength, 1f, 0.08f);
            ActivateVortexFilter(vortexStrength);

            player.Center = Vector2.Lerp(player.Center, portalWorldCenter, 0.045f + eased * 0.08f);
            PlayerVisualScale = MathHelper.Lerp(1f, 0f, eased);

            if (progress >= 1f || PlayerVisualScale <= 0.025f)
                FinishInfusion(player);
        }

        static void FinishInfusion(Player player)
        {
            if (phase == EventPhase.Transitioning)
                return;

            phase = EventPhase.Transitioning;
            WorldFlagsSystem.CompleteLightWallInfusion();
            player.GetModPlayer<Common.Players.AnomalyPlayer>().IsAnomaly = true;
            DecoyWorld.PreparePermanentDestruction();
            pendingPrimordialWake = true;
            RestoreMusic();

            // Сюжетный выход идёт в Первомир, в отличие от ручной кнопки
            // ReturnDestination=int.MinValue, которая ведёт в меню.
            SubworldSystem.MovePlayerToMainWorld(Main.myPlayer);
        }

        static void LockPlayer(Player player)
        {
            player.controlLeft = false;
            player.controlRight = false;
            player.controlUp = false;
            player.controlDown = false;
            player.controlJump = false;
            player.controlUseItem = false;
            player.controlUseTile = false;
            player.controlHook = false;
            player.velocity = Vector2.Zero;
            Main.playerInventory = false;
            player.controlInv = false;
        }

        static void EmitAmbientWallThreads(Player player, float wallWorldX, float distance)
        {
            if (distance > 1100f || Main.GameUpdateCount % 5 != 0)
                return;

            float y = player.Center.Y + Main.rand.NextFloat(-Main.screenHeight * 0.55f,
                Main.screenHeight * 0.55f);
            Vector2 position = new Vector2(wallWorldX + Main.rand.NextFloat(-5f, 5f), y);
            Vector2 towardPlayer = (player.Center - position).SafeNormalize(Vector2.UnitX);
            new LightThreadParticle(position, towardPlayer * Main.rand.NextFloat(0.15f, 0.45f),
                150 + Main.rand.Next(120)).Spawn();
        }

        static void ActivateStrikeFilter(Player player, float amount)
        {
            var filter = StoryDirector.LightStrikeFilter;
            if (filter == null)
                return;

            Vector2 playerScreen = player.MountedCenter - Main.screenPosition;
            Vector2 screenSize = new Vector2(Math.Max(1, Main.screenWidth),
                Math.Max(1, Main.screenHeight));
            Vector2 centerUV = playerScreen / screenSize;
            Vector2 halfSizeUV = new Vector2(
                Math.Max(16f, player.width * PlayerVisualScale * 0.8f) / screenSize.X,
                Math.Max(28f, player.height * PlayerVisualScale * 0.65f) / screenSize.Y);

            filter.TrySetParameter("flashAmount", amount);
            filter.TrySetParameter("playerCenter", centerUV);
            filter.TrySetParameter("playerHalfSize", halfSizeUV);
            filter.Activate();
        }

        static void ActivateVortexFilter(float strength)
        {
            var filter = StoryDirector.DistortionVortexFilter;
            if (filter == null)
                return;

            Vector2 portalScreen = portalWorldCenter - Main.screenPosition;
            Vector2 screenSize = new Vector2(Math.Max(1, Main.screenWidth),
                Math.Max(1, Main.screenHeight));

            filter.TrySetParameter("vortexCenter", portalScreen / screenSize);
            filter.TrySetParameter("vortexRadius", 0.28f);
            filter.TrySetParameter("vortexStrength", strength);
            filter.Activate();
        }

        static void StartHum()
        {
            if (SoundEngine.TryGetActiveSound(humSlot, out _))
                return;

            SoundStyle hum = SoundID.DD2_EtherianPortalIdleLoop with
            {
                Volume = 0.22f,
                IsLooped = true,
                MaxInstances = 1
            };
            humSlot = SoundEngine.PlaySound(hum, Main.LocalPlayer.Center,
                _ => phase is EventPhase.Countdown or EventPhase.BeamTravel);
        }

        static void RestoreMusic()
        {
            if (capturedMusicVolume)
                Main.musicVolume = originalMusicVolume;
            capturedMusicVolume = false;
        }

        static void ResetRuntime(bool restoreScale)
        {
            if (phase == EventPhase.Dormant)
                return;

            RestoreMusic();
            if (restoreScale)
                PlayerVisualScale = 1f;

            phase = EventPhase.Dormant;
            phaseTimer = 0;
            countdownTicksRemaining = 0;
            beamProgress = 0f;
            flashAmount = 0f;
            eyeOpacity = 0f;
            vortexStrength = 0f;
        }

        public override void ModifyScreenPosition()
        {
            if ((int)phase < (int)EventPhase.FlashFade
                || (int)phase >= (int)EventPhase.Transitioning)
                return;

            Vector2 focus = Vector2.Lerp(Main.LocalPlayer.Center, eyeWorldCenter, 0.35f);
            Vector2 target = focus - new Vector2(Main.screenWidth, Main.screenHeight) * 0.5f;
            Main.screenPosition = Vector2.Lerp(Main.screenPosition, target, 0.055f);
        }

        public override void PostDrawTiles()
        {
            if (!Active || Main.dedServ)
                return;

            SpriteBatch sb = Main.spriteBatch;
            bool begun = false;
            try
            {
                sb.Begin(SpriteSortMode.Deferred, BlendState.Additive,
                    SamplerState.LinearClamp, DepthStencilState.None,
                    Main.Rasterizer, null, Main.GameViewMatrix.TransformationMatrix);
                begun = true;

                if (phase == EventPhase.BeamTravel)
                    DrawBeam(sb);

                if ((int)phase >= (int)EventPhase.FlashFade
                    && (int)phase < (int)EventPhase.Transitioning)
                {
                    DrawCreatorEye(sb);
                    DrawPortal(sb);
                }

                sb.End();
                begun = false;
            }
            catch (Exception)
            {
                if (begun)
                {
                    try { sb.End(); } catch (Exception) { }
                }
            }
        }

        static void DrawBeam(SpriteBatch sb)
        {
            Vector2 end = Vector2.Lerp(beamStart, beamEnd, beamProgress);
            DrawLine(sb, beamStart - Main.screenPosition, end - Main.screenPosition,
                new Color(255, 246, 205) * 0.95f, 18f);
            DrawLine(sb, beamStart - Main.screenPosition, end - Main.screenPosition,
                Color.White, 5f);
        }

        static void DrawCreatorEye(SpriteBatch sb)
        {
            if (eyeOpacity <= 0.001f)
                return;

            Vector2 center = eyeWorldCenter - Main.screenPosition;
            float pulse = 1f + 0.055f * (float)Math.Sin(Main.GlobalTimeWrappedHourly * 2.4f);
            float width = 300f * pulse;
            float height = 125f * pulse;

            // 96 переплетённых хорд дают ощущение сотен нитей без тяжёлой
            // текстуры. Фаза каждой хорды детерминирована индексом.
            for (int i = 0; i < 96; i++)
            {
                float a = MathHelper.TwoPi * i / 96f;
                float b = a * 2.73f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * 0.7f) * 0.35f;
                Vector2 p1 = center + new Vector2((float)Math.Cos(a) * width,
                    (float)Math.Sin(a) * height);
                Vector2 p2 = center + new Vector2((float)Math.Cos(b) * width * 0.82f,
                    (float)Math.Sin(b) * height * 0.82f);
                Color color = Color.Lerp(new Color(255, 126, 16),
                    new Color(255, 238, 157), (i % 11) / 10f) * eyeOpacity * 0.38f;
                DrawLine(sb, p1, p2, color, 1.35f);
            }

            // Зрачок «дышит» концентрическими янтарными кольцами.
            for (int ring = 0; ring < 7; ring++)
            {
                float radius = (18f + ring * 7f) * pulse;
                DrawCircle(sb, center, radius,
                    new Color(255, 191, 58) * eyeOpacity * (1f - ring / 8f), 2f);
            }
        }

        static void DrawPortal(SpriteBatch sb)
        {
            float opacity = eyeOpacity * vortexStrength;
            if (opacity <= 0.001f)
                return;

            Vector2 center = portalWorldCenter - Main.screenPosition;
            for (int arm = 0; arm < 48; arm++)
            {
                float baseAngle = MathHelper.TwoPi * arm / 48f
                    + Main.GlobalTimeWrappedHourly * 0.65f;
                Vector2 previous = center;
                for (int step = 1; step <= 8; step++)
                {
                    float radius = step * 10f;
                    float angle = baseAngle + step * 0.34f;
                    Vector2 current = center + angle.ToRotationVector2() * radius;
                    DrawLine(sb, previous, current,
                        new Color(255, 151, 25) * opacity * (1f - step / 10f), 1.4f);
                    previous = current;
                }
            }
        }

        static void DrawCircle(SpriteBatch sb, Vector2 center,
            float radius, Color color, float thickness)
        {
            const int segments = 48;
            Vector2 previous = center + Vector2.UnitX * radius;
            for (int i = 1; i <= segments; i++)
            {
                float angle = MathHelper.TwoPi * i / segments;
                Vector2 current = center + angle.ToRotationVector2() * radius;
                DrawLine(sb, previous, current, color, thickness);
                previous = current;
            }
        }

        static void DrawLine(SpriteBatch sb, Vector2 start,
            Vector2 end, Color color, float thickness)
        {
            Vector2 delta = end - start;
            float length = delta.Length();
            if (length <= 0.01f)
                return;

            sb.Draw(TextureAssets.MagicPixel.Value, start, null, color,
                delta.ToRotation(), new Vector2(0f, 0.5f),
                new Vector2(length, thickness), SpriteEffects.None, 0f);
        }

        public override void ModifyInterfaceLayers(List<GameInterfaceLayer> layers)
        {
            if (!LocksPlayer)
                return;

            // Катсцена полностью кинематографична, но список слоёв не удаляется.
            foreach (GameInterfaceLayer layer in layers)
            {
                switch (layer.Name)
                {
                    case "Vanilla: Resource Bars":
                    case "Vanilla: Inventory":
                    case "Vanilla: Hotbar":
                    case "Vanilla: Map / Minimap":
                    case "Vanilla: Player Chat":
                        layer.Active = false;
                        break;
                }
            }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/ModAssetHelper.cs
================================================================
using System;
using Terraria.ModLoader;

namespace ShatteredGenesis.Common.Systems
{
    // Проверяет наличие необязательного ассета до Request, чтобы отсутствие
    // картинки не приводило к ошибке загрузки мода.
    internal static class ModAssetHelper
    {
        public static bool Exists(string relativePath)
        {
            try
            {
                Mod mod = ModLoader.GetMod("ShatteredGenesis");
                return mod != null && mod.HasAsset(relativePath);
            }
            catch (Exception)
            {
                return false;
            }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/StoryDirector.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Graphics; // DrawString под DynamicSpriteFont живёт тут (урок 0.4.23.1)
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using SubworldLibrary;
using Luminance.Core.Graphics; // ManagedShader / ManagedScreenFilter / ShaderManager
using ShatteredGenesis.Content.Subworlds;

// ============================================================
// StoryDirector — КЛЕЙ ГЛУБОКОЙ ИНТЕГРАЦИИ каркаса истории.
//
// КАНОН 0.4.26 «Глубокая интеграция» (указ режиссёра №0.4.26-2/3):
//   * ОСНОВНОЙ мир — ЧИСТАЯ ваниль. Никакой графики, звука,
//     фризов и капсул на его генерации. Точка.
//   * Нестабильный перехват SoundEngine через RuntimeDetour удалён в G6.
//     Звуки стройки подмира закрывает SubworldAudioSilencer: перед
//     переходом он запоминает громкость эффектов и окружения, на время
//     загрузки удерживает их в нуле и возвращает при входе. Настройки
//     на диск не записываются, музыка не затрагивается. Остальные системы:
//       - виньетка экрана загрузки → шейдер SleepVeil через
//         ShaderManager Luminance (не 24 полоски MagicPixel);
//       - искры у костра → частицы Luminance Particle/ParticleManager
//         (в мире; на экране загрузки они физически не тикают —
//         PartiсleManager живёт в PostUpdateDusts, исходник сверен);
//       - сон в финале — фильтр SleepFade через ManagedScreenFilter
//         Luminance (мировой экранный конвейер) + чернь;
//       - часы у катсцен → Main.GameUpdateCount (аппаратные 60 Гц);
//       - геометрия кадра → движковые UI-поля без округлений;
//       - перенос текста → ChatManager (системный конвейер чата).
//   * DAYBREAK остаётся жёсткой зависимостью других систем мода;
//     для подавления звука генерации он больше не используется.
//
// Здесь живёт направление ИСТОРИИ:
//   EnterStory()      — перенос в подмир-обманку (шов + окно тишины);
//   Veil/Sleep-кэш    — ОДИН забор эффектов Luminance после их загрузки
//                       (аудит №3: в отрисовке — ни одного поиска
//                       по словарям, только .Activate()/Apply);
//   DrawStamp()       — печать сборки на наших экранах (урок 0.4.25.3);
//   занавес шва       — ровно на кадры перелёта, не дольше;
//   окно тишины       — действует только во время перехода в Скорлупу.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
    public class StoryDirector : ModSystem
    {
        // Число слайдов-«видений», используемых текстовой сценой.
        public const int SlideCount = 8;

        // Поднят занавес поверх перелёта в подмир (между Raise в
        // AnomalyPlayer и Lift в DecoyWorld.OnEnter).
        static bool hopCurtain;

        // ===================== КЭШ ЭФФЕКТОВ LUMINANCE (аудит №3) =====================
        // ОДИН раз забираем ссылки после того, как библиотека собрала
        // эффекты (PostSetupContent идёт ПОСЛЕ Luminance.PostSetupContent —
        // порядок гарантирован жёсткой зависимостью). В отрисовке
        // обращения по строковым словарям ЗАПРЕЩЕНЫ — только готовые ссылки.
        public static ManagedShader VeilShader;        // пелена экрана загрузки
        public static ManagedScreenFilter SleepFilter; // фильтр засыпания/пробуждения
        public static ManagedScreenFilter CampDarknessFilter; // абсолютная ночь привала
        public static ManagedScreenFilter LightStrikeFilter; // белый удар / чёрный силуэт
        public static ManagedScreenFilter DistortionVortexFilter; // портал Ока

        // Luminance пересобирает .fx АСИНХРОННО (ShaderRecompilationMonitor,
        // сверено): на момент PostSetupContent .fxc может ещё не существовать.
        // Дозабор — раз в секунду и только пока ссылка пуста: это не
        // «каждокадровый перебор», а один словарный запрос в 60 тиков.
        static void TryCacheEffects()
        {
            if (VeilShader == null)
            {
                try { ShaderManager.TryGetShader("ShatteredGenesis.SleepVeil", out VeilShader); }
                catch (Exception) { /* реестр ещё не создан — дождёмся */ }
            }

            if (SleepFilter == null)
            {
                try { ShaderManager.TryGetFilter("ShatteredGenesis.SleepFade", out SleepFilter); }
                catch (Exception) { /* аналогично */ }
            }

            if (CampDarknessFilter == null)
            {
                try { ShaderManager.TryGetFilter("ShatteredGenesis.CampDarkness", out CampDarknessFilter); }
                catch (Exception) { /* фильтр ещё компилируется */ }
            }

            if (LightStrikeFilter == null)
            {
                try { ShaderManager.TryGetFilter("ShatteredGenesis.LightStrike", out LightStrikeFilter); }
                catch (Exception) { /* фильтр ещё компилируется */ }
            }

            if (DistortionVortexFilter == null)
            {
                try { ShaderManager.TryGetFilter("ShatteredGenesis.DistortionVortex", out DistortionVortexFilter); }
                catch (Exception) { /* фильтр ещё компилируется */ }
            }
        }

        public override void PostSetupContent() => TryCacheEffects();

        public override void Load()
        {
            On_Main.Draw += LoadCurtain;
        }

        public override void Unload()
        {
            On_Main.Draw -= LoadCurtain;

            // Ссылки на эффекты Luminance умирают вместе с нами.
            VeilShader = null;
            SleepFilter = null;
            CampDarknessFilter = null;
            LightStrikeFilter = null;
            DistortionVortexFilter = null;
        }

        public static void RaiseCurtain() => hopCurtain = true;
        public static void LiftCurtain() => hopCurtain = false;

        // ===================== СЮЖЕТНЫЙ ПЕРЕНОС =====================
        // Единственная кнопка «бросить игрока в мир истории».
        // Зовётся из AnomalyPlayer (первый заход) и из отладочной /sgworld.
        public static void EnterStory()
        {
            // Печать Вселения (канон 0.4.26-4): Скорлупа разрушена ивентом —
            // ни автоперенос, ни отладочный вход её больше не откроют.
            if (WorldFlagsSystem.ShellDestroyed)
                return;

            // Запоминаем настройки игрока и глушим эффекты/окружение ДО
            // запуска генератора. OnEnter вернёт значения после загрузки.
            SubworldAudioSilencer.Begin();
            RaiseCurtain(); // чёрный шов перелёта — между мирами, не в мире

            try
            {
                // Enter возвращает false, если переход даже не стартовал.
                // В этом случае OnEnterWorld не придёт и звук возвращаем сами.
                if (!SubworldSystem.Enter<DecoyWorld>())
                {
                    LiftCurtain();
                    SubworldAudioSilencer.End();
                }
            }
            catch
            {
                // Синхронный отказ перехода не должен оставить игру без звука.
                LiftCurtain();
                SubworldAudioSilencer.End();
                throw;
            }
        }

        // ===================== Update-канал: страховки =====================
        public override void PostUpdateEverything()
        {
            // Редкий дозабор эффектов Luminance (аудит №3) — раз в секунду
            // по аппаратным апдейтам движка, и только пока кэш пуст.
            if ((VeilShader == null || SleepFilter == null || CampDarknessFilter == null
                    || LightStrikeFilter == null || DistortionVortexFilter == null)
                && Main.GameUpdateCount % 60 == 0)
            {
                TryCacheEffects();
            }

            // Страховка аварийного возврата и тайм-аут живут внутри
            // SubworldAudioSilencer; здесь не дублируем его состояние.
        }

        // ===================== Занавес шва =====================
        // ОДНА обязанность: чёрный шов перелёта в подмир. Рисуем ПОСЛЕ
        // orig (кадр движка закончен, батч свободен — урок 0.4.25.2).
        // Во время генераций не лезем никуда: создание основного мира —
        // чистая ваниль; стройка подмира — DecoyWorld.DrawSetup.
        void LoadCurtain(On_Main.orig_Draw orig, Main self, GameTime gameTime)
        {
            orig(self, gameTime);

            if (!hopCurtain || WorldGen.generatingWorld)
                return;

            Main.instance.GraphicsDevice.Clear(Color.Black);
        }

        // ===================== Печать сборки (урок 0.4.25.3) =====================
        // На каждом нашем кадре — строка с версией: по скриншоту сразу
        // видно, КАКАЯ сборка реально запущена у режиссёра.
        static string stamp;

        public static void DrawStamp(SpriteBatch sb, float screenW, float screenH)
        {
            if (stamp == null)
            {
                try
                {
                    Mod mod = ModLoader.GetMod("ShatteredGenesis");
                    stamp = mod != null
                        ? $"Shattered Genesis v{mod.Version}"
                        : "Shattered Genesis (dev)";
                }
                catch (Exception)
                {
                    stamp = "Shattered Genesis (dev)";
                }
            }

            var font = FontAssets.MouseText.Value;
            const float scale = 0.85f;
            Vector2 size = font.MeasureString(stamp) * scale;
            Vector2 pos = new Vector2(8f, screenH - size.Y - 8f); // левый нижний угол

            sb.DrawString(font, stamp, pos + new Vector2(1f, 1f),
                Color.Black * 0.8f, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, stamp, pos,
                new Color(140, 140, 140) * 0.55f, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/SubworldAudioSilencer.cs
================================================================
using System;
using Terraria;
using Terraria.Audio;
using Terraria.ModLoader;

namespace ShatteredGenesis.Common.Systems
{
    // Заглушает переход и всю загрузку Скорлупы. Значения громкости
    // игрока сохраняются в памяти и возвращаются только в OnEnterWorld,
    // когда подмир уже полностью создан и игрок действительно вошёл.
    // Никакой рефлексии, RuntimeDetour и записи настроек на диск.
    public sealed class SubworldAudioSilencer : ModSystem
    {
        static bool active;
        static float savedSoundVolume;
        static float savedAmbientVolume;
        static DateTime startedAtUtc;

        public static bool Active => active;

        public static void Begin()
        {
            if (!active)
            {
                savedSoundVolume = Main.soundVolume;
                savedAmbientVolume = Main.ambientVolume;
                startedAtUtc = DateTime.UtcNow;
                active = true;
            }

            ApplyMute();
            StopTransientSounds();
        }

        public static void End()
        {
            if (!active)
                return;

            // Сначала убираем уже созданные, но пока неслышимые звуки
            // генератора, и лишь затем возвращаем настройки игрока.
            StopTransientSounds();
            Main.soundVolume = savedSoundVolume;
            Main.ambientVolume = savedAmbientVolume;

            active = false;
        }

        public override void PostUpdateEverything()
        {
            if (!active)
                return;

            // Некоторые этапы генерации могут трогать общие поля звука.
            // Пока переход не закончен, удерживаем эффект и эмбиент в нуле.
            ApplyMute();

            // Не восстанавливаем звук по Main.gameMenu: Subworld Library
            // держит меню открытым ещё ДО начала генерации. Ранний таймер
            // как раз возвращал громкость перед установкой блоков.

            // Последняя страховка от зависшего стороннего перехода.
            if (DateTime.UtcNow - startedAtUtc > TimeSpan.FromMinutes(10))
                End();
        }

        public override void Unload() => End();

        static void ApplyMute()
        {
            Main.soundVolume = 0f;
            Main.ambientVolume = 0f;
        }

        static void StopTransientSounds()
        {
            try { SoundEngine.StopTrackedSounds(); } catch (Exception) { }
            try { SoundEngine.StopAmbientSounds(); } catch (Exception) { }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/VanillaGenSnapshot.cs
================================================================
using System.Collections.Generic;
using Terraria.WorldBuilding;

// VanillaGenSnapshot — слепок настоящего ванильного конвейера генерации
// вместе с его чертежом.
//
// История эксперимента:
//  * 0.3.4.2 «слепок» — задачи без конфигурации → NRE в TerrainPass (строке
//    нужен GameConfiguration с чертежом размеров мира).
//  * 0.3.4.3 «весь станок» — WorldGen.GenerateWorld внутри под-мира →
//    споткнулся о внутреннюю бухгалтерию (WorldGen.cs ~1300).
//  * 0.3.4.4 «чертёж и станок» — Snapshot + Config: у Subworld есть родной
//    слот Config, а готовый ванильный WorldGenConfiguration доступен в
//    GenVars.configuration в момент создания основного мира. Хватаем оба
//    одним движением — и Subworld Library сама кормит каждому проходу его
//    кусок чертежа по имени.
namespace ShatteredGenesis.Common.Systems
{
    public static class VanillaGenSnapshot
    {
        // Скорлупа всегда использует настоящий ванильный конвейер.
        // Отладочный переключатель на ручную копию удалён в 0.4.26.7.

        // Слепок проходов и чертёж. null — мир с модом не создавался в сессии.
        private static List<GenPass> passes;
        private static WorldGenConfiguration config;

        public static bool Ready => passes != null && passes.Count > 0 && config != null;
        public static int Count => passes?.Count ?? 0;

        // Чертёж для слота Subworld.Config — Subworld Library вызывает его
        // до генерации и раздаёт проходам их персональные куски.
        public static WorldGenConfiguration Config => config;

        // Снимаем КОПИЮ списка + ссылку на чертёж. Вызывается из
        // FirstWorldGenSystem ПЕРЕД расчисткой задач под Первомир.
        public static void Capture(List<GenPass> tasks, WorldGenConfiguration currentConfig)
        {
            passes = new List<GenPass>(tasks);
            config = currentConfig;
        }

        // Свежая копия слепка + наш финишный проход (спавн, Гид).
        public static List<GenPass> BuildTaskList(GenPass finisher)
        {
            if (!Ready)
                throw new System.InvalidOperationException(
                    "[ShatteredGenesis] Ванильный конвейер не захвачен. " +
                    "Создай новый основной мир после Build + Reload.");

            var list = new List<GenPass>(passes);
            if (finisher != null)
                list.Add(finisher);
            return list;
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/WorldFlagsSystem.cs
================================================================
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;

// WorldFlagsSystem — память мира. Хранит сюжетные флаги,
// которые должны переживать перезаходы:
//   IntroDone     — интро «Привал» показали (не заставляем смотреть снова)
//   InfusionDone  — Вселение произошло (игрока выкинуло в Первомир)
//   ShellDestroyed — обманка-Скорлупа разрушена ивентом (вход запечатан)
//
// КАНОН 0.4.26-4 «Вечная Скорлупа» — ЛЕЧЕНИЕ КОЛЬЦА (баг, пойманный
// режиссёром): раньше загрузка мира СТИРАЛА память значением из файла.
// Сцена кончалась в подмире (файл основного мира этого ещё не знал) —
// возврат в основной мир откатывал флаг на false, и игрока снова
// бросало на генерацию. Бесконечное кольцо.
// Теперь: флаги МОНОТОННЫ — «истина» никогда не откатывается загрузкой;
// новые миры сбрасываются отдельно (PostWorldGen / отсутствие ключа).
namespace ShatteredGenesis.Common.Systems
{
    public class WorldFlagsSystem : ModSystem
    {
        public static bool IntroDone;
        public static bool InfusionDone;
        public static bool ShellDestroyed; // Скорлупа разрушена Вселением

        // Сохранение мира — просто кладём наши флаги в общий контейнер.
        public override void SaveWorldData(TagCompound tag)
        {
            tag["sg_introDone"] = IntroDone;
            tag["sg_infusionDone"] = InfusionDone;
            tag["sg_shellDestroyed"] = ShellDestroyed;
        }

        // Загрузка мира — МОНОТОННО (канон 0.4.26-4): пройденная история
        // не откатывается. Если ключей нет вообще — мир впервые входит в
        // память (или очень старый сейв) → история начинается с чистого листа.
        public override void LoadWorldData(TagCompound tag)
        {
            bool hasKey = tag.ContainsKey("sg_introDone");

            bool intro = tag.TryGet("sg_introDone", out bool i) && i;
            bool infusion = tag.TryGet("sg_infusionDone", out bool f) && f;
            bool shell = tag.TryGet("sg_shellDestroyed", out bool s) && s;

            if (!hasKey)
            {
                IntroDone = false;
                InfusionDone = false;
                ShellDestroyed = false;
                return;
            }

            IntroDone = IntroDone || intro;
            InfusionDone = InfusionDone || infusion;
            ShellDestroyed = ShellDestroyed || shell;
        }

        // Хук сразу после создания мира: новорождённый мир начинает
        // историю с чистого листа (и не может нести чужой Скорлупы).
        public override void PostWorldGen()
        {
            IntroDone = false;
            InfusionDone = false;
            ShellDestroyed = false;
            DedupeGuides(); // баг №3: один Гид, не близнецы
        }

        // ===================== РАЗРУШЕНИЕ СКОРЛУПЫ =====================
        // Зовётся ивентом Вселения: игрока выкинуло в Первомир — обманке
        // конец. Печать вечна: ни автоперенос, ни /sgworld её больше
        // не откроют. (Физическое стирание файлов подмира подвесим сюда
        // на этапе строительства самого ивента — через проверенный API
        // библиотеки, по нашему уставу без выдумок.)
        public static void CompleteLightWallInfusion()
        {
            IntroDone = true;
            InfusionDone = true;
            ShellDestroyed = true;
        }

        public static void DestroyShell()
            => CompleteLightWallInfusion();

        // Санитар: растворяет всех активных Гидов, кроме первого.
        // Зовётся из PostWorldGen и страховочно при входе в мир/обманку.
        public static void DedupeGuides()
        {
            // Клиент мультиплеера не правит НПС — придёт синхронизация хоста.
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            bool kept = false;
            for (int i = 0; i < Main.maxNPCs; i++)
            {
                NPC npc = Main.npc[i];
                if (!npc.active || npc.type != NPCID.Guide)
                    continue;

                if (!kept)
                {
                    kept = true; // первый по списку остаётся жить
                    continue;
                }

                npc.active = false; // дубликат растворяется без звука и дыма
            }
        }
    }
}

================================================================
=== ФАЙЛ: Content/Menus/GenesisMenu.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics; // DrawString под DynamicSpriteFont живёт тут (урок 0.4.23.1)
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

// GenesisMenu — кастомное меню ТИТУЛЬНОГО экрана (канон 0.4.25 «Капсула»).
//
// ЧЕСТНАЯ ГРАНИЦА МЕХАНИКИ tML (записано техотделом для летописи):
// ModMenu — это кожа титульного экрана и ТОЛЬКО его. Метод OnSelected()
// срабатывает, когда игра переключает ТИТУЛ на это меню — «моментом
// генерации мира» он быть не может: экраны создания мира рисует ванильный
// Main.DrawMenu, а подмир — Subworld Library. Поэтому архитектура у нас
// трёхслойная, и у каждого слоя свой законный приёмник:
//   титул          → GenesisMenu (этот файл);
//   создание мира  → Капсула в StoryDirector (ванильный экран не вызывается);
//   стройка подмира → DecoyWorld.DrawSetup (законный override библиотеки).
namespace ShatteredGenesis.Content.Menus
{
    public class GenesisMenu : ModMenu
    {
        public override string DisplayName => "Shattered Genesis";

        // Хронометр титула (дыхание костра). Тикает в Update-канале.
        static int ticks;

        static bool artTried;
        static Texture2D art;

        // Вызывается, когда ТИТУЛ переключают на наше меню.
        public override void OnSelected()
        {
            ticks = 0;
        }

        public override void OnDeselected() { }

        // Update-канал титула: хронометр живёт здесь, а не в отрисовке.
        public override void Update(bool isOnTitleScreen) => ticks++;

        // Рисование в ModMenu этой сборки tML: метода Draw(SpriteBatch)
        // НЕ СУЩЕСТВУЕТ (урок 0.4.25.1, CS0115; сверено с исходником ветки
        // 1.4.4). Законные точки кисти — PreDrawLogo и PostDrawLogo: обе
        // дёргаются уже в открытый движком батч (UI-пространство).
        // Свой титул рисуем в PostDrawLogo; стандартный лого при этом гасим.
        public override bool PreDrawLogo(SpriteBatch spriteBatch, ref Vector2 logoDrawCenter,
            ref float logoRotation, ref float logoScale, ref Color drawColor)
            => false; // чужой лого на нашем плакате не нужен

        public override void PostDrawLogo(SpriteBatch sb, Vector2 logoDrawCenter,
            float logoRotation, float logoScale, Color drawColor)
        {
            TryArt();

            // Батч открыт в UI-масштабе — меряем логические единицы:
            // пиксели вьюпорта ÷ Main.UIScale (точная геометрия).
            var vp = Main.instance.GraphicsDevice.Viewport;
            float ui = Main.UIScale;
            if (ui <= 0.01f)
                ui = 1f;
            int sw = (int)(vp.Width / ui + 1f);
            int sh = (int)(vp.Height / ui + 1f);

            // Чернь титула. Ванильный фон меню накрывается полностью.
            sb.Draw(TextureAssets.MagicPixel.Value,
                new Rectangle(-2, -2, sw + 4, sh + 4), Color.Black);

            if (art != null)
            {
                // Вписываем костёр целиком, по центру; дыхание — как у углей.
                float k = Math.Min(sw / (float)art.Width, sh / (float)art.Height);
                int w = (int)(art.Width * k);
                int h = (int)(art.Height * k);
                float alpha = 0.55f + 0.2f * (float)Math.Sin(ticks * 0.02f);
                sb.Draw(art, new Rectangle((sw - w) / 2, (sh - h) / 2, w, h),
                    Color.White * alpha);
            }

            // Титульная вязь — янтарём, там, где обычно сидит лого.
            // Горизонтальную середину берём из якоря движка logoDrawCenter.X
            // (Main.screenWidth в менюшных кадрах заказчику врал — урок 0.4.24).
            var font = FontAssets.DeathText.Value;
            DrawLine(sb, font, "SHATTERED GENESIS",
                logoDrawCenter.X, logoDrawCenter.Y, 0.55f, new Color(255, 196, 60));
            DrawLine(sb, font, "— Расколотый Первомир —",
                logoDrawCenter.X, logoDrawCenter.Y + 52f, 0.30f, new Color(196, 150, 38));

            StoryDirector.DrawStamp(sb, sw, sh); // печать сборки (урок 0.4.25.3)
        }

        // Строка по центру кадра с чёрной каймой (читается на любом фоне).
        static void DrawLine(SpriteBatch sb, ReLogic.Graphics.DynamicSpriteFont font,
                             string text, float centerX, float centerY, float scale, Color color)
        {
            Vector2 size = font.MeasureString(text) * scale;
            Vector2 pos = new Vector2(centerX - size.X * 0.5f,
                                      centerY - size.Y * 0.5f);
            Color shade = Color.Black * 0.9f;
            sb.DrawString(font, text, pos + new Vector2(2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(-2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, 2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, -2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos, color, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
        }

        // Арт щупаем строго через HasAsset (урок 0.4.21.1: слепой Request
        // несуществующего файла на новых сборках tML — фатальная модалка).
        static void TryArt()
        {
            if (artTried)
                return;
            artTried = true;

            if (!ModAssetHelper.Exists("Assets/Slides/CampScene.png"))
                return; // нет арта — титул честно чёрный

            try
            {
                art = ModContent.Request<Texture2D>(
                    "ShatteredGenesis/Assets/Slides/CampScene",
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                art = null; // бронежилет
            }
        }
    }
}

================================================================
=== ФАЙЛ: Content/NPCs/StoryTeller.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using Terraria;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

namespace ShatteredGenesis.Content.NPCs
{
    // Сказитель существует только внутри сцены у костра. Это не городской NPC:
    // он не открывает ванильное окно чата, не получает урон и не уходит сам.
    public sealed class StoryTeller : ModNPC
    {
        static Texture2D markerTexture;
        static bool markerLoadTried;

        public override void SetStaticDefaults()
        {
            Main.npcFrameCount[Type] = 4;
        }

        public override void SetDefaults()
        {
            NPC.width = 34;
            NPC.height = 54;
            NPC.friendly = true;
            NPC.dontTakeDamage = true;
            NPC.lifeMax = 250;
            NPC.damage = 0;
            NPC.defense = 0;
            NPC.aiStyle = -1;
            NPC.noGravity = true;
            NPC.noTileCollide = true;
            NPC.netAlways = true;
            NPC.alpha = 0;
        }

        public override bool CheckActive() => false;
        public override bool CanChat() => false;

        public override void AI()
        {
            if (!CampfireScene.Active)
            {
                NPC.active = false;
                return;
            }

            if (!CampfireScene.StorytellerShouldLeave)
            {
                NPC.Bottom = CampfireScene.StorytellerAnchor;
                NPC.velocity = Vector2.Zero;
                NPC.direction = -1;
                NPC.spriteDirection = -1;
                NPC.alpha = Math.Max(0, NPC.alpha - 8);
                return;
            }

            // Не исчезаем по щелчку: медленно уходим от костра и растворяемся
            // в абсолютной темноте. Движок применит velocity после AI.
            NPC.noGravity = true;
            NPC.noTileCollide = true;
            NPC.velocity = new Vector2(0.48f, 0f);
            NPC.direction = 1;
            NPC.spriteDirection = 1;
            NPC.alpha = Math.Min(255, NPC.alpha + 3);

            if (NPC.alpha >= 255)
            {
                NPC.active = false;
                CampfireScene.NotifyStorytellerGone();
            }
        }

        public override void FindFrame(int frameHeight)
        {
            NPC.frameCounter += CampfireScene.StorytellerShouldLeave ? 0.20 : 0.10;
            int frame = (int)NPC.frameCounter % Main.npcFrameCount[Type];
            NPC.frame.Y = frame * frameHeight;
        }

        public override void PostDraw(SpriteBatch spriteBatch, Vector2 screenPos, Color drawColor)
        {
            if (!CampfireScene.MarkerVisible || NPC.alpha >= 240)
                return;

            TryLoadMarker();
            if (markerTexture == null)
                return;

            const int frameCount = 4;
            int frameHeight = markerTexture.Height / frameCount;
            int frame = (int)(Main.GameUpdateCount / 8 % frameCount);
            Rectangle source = new Rectangle(0, frame * frameHeight,
                markerTexture.Width, frameHeight);

            float pulse = 0.88f + 0.12f * (float)Math.Sin(Main.GameUpdateCount * 0.13f);
            Vector2 position = NPC.Top - screenPos + new Vector2(0f, -18f);
            Vector2 origin = new Vector2(source.Width * 0.5f, source.Height);

            // Два прохода дают читаемое янтарное свечение даже в фильтре тьмы.
            spriteBatch.Draw(markerTexture, position, source,
                new Color(255, 126, 18) * (0.30f * pulse),
                0f, origin, 1.32f, SpriteEffects.None, 0f);
            spriteBatch.Draw(markerTexture, position, source,
                Color.White * pulse,
                0f, origin, 1f, SpriteEffects.None, 0f);
        }

        static void TryLoadMarker()
        {
            if (markerLoadTried)
                return;
            markerLoadTried = true;

            try
            {
                markerTexture = ModContent.Request<Texture2D>(
                    "ShatteredGenesis/Assets/UI/StoryMarker",
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                markerTexture = null;
            }
        }

        public override void Unload()
        {
            markerTexture = null;
            markerLoadTried = false;
        }
    }
}

================================================================
=== ФАЙЛ: Content/Particles/EmberParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Luminance.Core.Graphics;

// ============================================================
// EmberParticle — искра сюжетного костра на движке Luminance.
//
// КАНОН 0.4.26 «Глубокая интеграция»: кустарная мини-система частиц
// CampfireScene (массивы ex/ey/evx…) УДАЛЕНА. Теперь искры — настоящие
// частицы: тикают в PartiсleManager.PostUpdateDusts, рисуются в
// On_Main.DrawDust — то есть ГЛУБОКО внутри мирового пайплайна,
// а не поверх UI нашим кодом.
//
// Текстура — с общего атласа Luminance (Assets/Atlases/StoryParticles):
//   AtlasTextureName обязан начинаться с "ИмяМода." — иначе атлас
//   её не найдёт (правило библиотеки, сверено с исходником
//   Particle.cs / AtlasManager.cs).
//
// БРОНЯ: если атлас не прочитался (атласная JSON-разметка — внешний
// формат), Texture будет null — рисуем ничего, игру не роняем.
// В логе Luminance при этом сам напишет, чего не нашла.
// ============================================================
namespace ShatteredGenesis.Content.Particles
{
    public sealed class EmberParticle : Particle
    {
        // Имя субтекстуры на атласе: шаблон "<Мод>.<Имя>" (канон библиотеки).
        public override string AtlasTextureName => "ShatteredGenesis.Ember";

        // Искра светится — аддитивное смешивание, как у пламени.
        public override BlendState BlendState => BlendState.Additive;

        public EmberParticle(Vector2 position, Vector2 velocity, Color color, int lifetimeTicks)
        {
            Position = position;
            Velocity = velocity;
            DrawColor = color;
            Lifetime = lifetimeTicks; // PartiсleManager сравнивает Time >= Lifetime — в тиках
            Scale = Vector2.One;
            Rotation = Main.rand.NextFloat(-MathHelper.Pi, MathHelper.Pi);
            RotationSpeed = Main.rand.NextFloat(-0.03f, 0.03f);
        }

        public override void Update()
        {
            // Искра гаснет: замедляется, чуть тянется вбок от жара,
            // и краснеет по мере старения (жар → тлеющий рубин).
            Velocity *= 0.965f;
            Velocity += new Vector2(
                (float)Math.Sin(Time * 0.11 + Position.X) * 0.025f, -0.010f);

            Rotation += RotationSpeed;

            float lifeLeft = 1f - LifetimeRatio;
            Opacity = lifeLeft * lifeLeft; // квадратичное остывание — живее
            DrawColor = Color.Lerp(
                new Color(120, 34, 8),    // почти остывший уголёк
                new Color(255, 226, 140), // только что сорвалась с полена
                lifeLeft);
            Scale = Vector2.One * (0.55f + 0.45f * lifeLeft);
        }

        public override void Draw(SpriteBatch spriteBatch)
        {
            // БРОНЯ: атласа нет — не рисуем и не падаем.
            if (Texture is null)
                return;

            base.Draw(spriteBatch);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Particles/FireflyParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Luminance.Core.Graphics;

// ============================================================
// FireflyParticle — ночной светляк над поляной привала.
// Тот же канон 0.4.26 «Глубокая интеграция»: движок частиц
// Luminance (Particle/ParticleManager), атлас StoryParticles.
// Плывёт ленивой синусоидой, мерцает, живёт дольше искры.
// ============================================================
namespace ShatteredGenesis.Content.Particles
{
    public sealed class FireflyParticle : Particle
    {
        public override string AtlasTextureName => "ShatteredGenesis.Firefly";

        // Светляк светит сам по себе — аддитив, но слабенький.
        public override BlendState BlendState => BlendState.Additive;

        readonly float swaySeed;

        public FireflyParticle(Vector2 position, Vector2 velocity, int lifetimeTicks)
        {
            Position = position;
            Velocity = velocity;
            DrawColor = new Color(214, 255, 150); // зеленовато-янтарный огонёк
            Lifetime = lifetimeTicks;
            Scale = Vector2.One * 0.8f;
            swaySeed = Main.rand.NextFloat(100f);
        }

        public override void Update()
        {
            // Ленивый дрейф + мерцание: огонёк то гаснет, то разгорается.
            Velocity *= 0.98f;
            Velocity += new Vector2(
                (float)Math.Sin(Time * 0.045 + swaySeed) * 0.05f,
                (float)Math.Cos(Time * 0.037 + swaySeed * 1.7f) * 0.03f);

            float shimmer = 0.55f + 0.45f * (float)Math.Sin(Time * 0.14 + swaySeed * 2.3f);
            float fade = (float)Math.Sin(LifetimeRatio * Math.PI); // вплыл — догорел
            Opacity = shimmer * fade;
        }

        public override void Draw(SpriteBatch spriteBatch)
        {
            if (Texture is null)
                return; // броня на сломанный атлас

            base.Draw(spriteBatch);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Particles/LightThreadParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Luminance.Core.Graphics;
using Terraria;

namespace ShatteredGenesis.Content.Particles
{
    // Медленная нить света Стены. Использует существующий Ember-регион
    // атласа Luminance, но вытягивается по вертикали и дрейфует к игроку.
    public sealed class LightThreadParticle : Particle
    {
        public override string AtlasTextureName => "ShatteredGenesis.Ember";
        public override BlendState BlendState => BlendState.Additive;

        readonly float phase;

        public LightThreadParticle(Vector2 position, Vector2 velocity, int lifetime)
        {
            Position = position;
            Velocity = velocity;
            Lifetime = lifetime;
            DrawColor = new Color(255, 202, 78);
            Scale = new Vector2(0.45f, 1.8f);
            phase = Main.rand.NextFloat(MathHelper.TwoPi);
        }

        public override void Update()
        {
            Velocity *= 0.985f;
            Velocity.Y += (float)Math.Sin(Time * 0.055f + phase) * 0.006f;
            Position += Vector2.UnitY * (float)Math.Sin(Time * 0.04f + phase) * 0.12f;

            float envelope = (float)Math.Sin(LifetimeRatio * Math.PI);
            Opacity = envelope;
            Scale = new Vector2(0.38f, 1.4f + envelope * 1.2f);
        }

        public override void Draw(SpriteBatch spriteBatch)
        {
            if (Texture is null)
                return;
            base.Draw(spriteBatch);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Subworlds/DecoyWorld.cs
================================================================
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics; // урок 0.4.23.1: без него SpriteBatch.DrawString НЕ видит
                        // перегрузки под DynamicSpriteFont (ReLogic кладёт их сюда) —
                        // CS1503 «не преобразовать в SpriteFont». CampfireScene знал.
using SubworldLibrary;
using Terraria;
using Terraria.GameContent;
using Terraria.GameContent.Generation;
using Terraria.GameInput;
using Terraria.ID;
using Terraria.IO;
using Terraria.ModLoader;
using Terraria.WorldBuilding;
using Terraria.Utilities;
using ShatteredGenesis.Common.Systems;
using ShatteredGenesis.Content.Tiles;

// DecoyWorld — мир-обманка (subworld). «Скорлупа» Первомира.
//
// v0.3.4 «Слоёвка Строителя» — переработка по ТЗ режиссёра:
//  * ландшафт приближен к ванильному: октавы + огибающая долин + холмы-горбы;
//  * пещерные устья НЕ появляются у спавна (за 450 блоков от центра — табу);
//  * слои: поверхность = уровень моря; подземелье до 500 — земля с камнем;
//    ниже — чистый камень;
//  * руды — по каноничной ванильной формуле «Shinies»: три глубинных пояса,
//    счёт от площади мира (6E-05 / 8E-05 / 0.0002 для меди), силы и длины
//    TileRunner как в оригинале.
//
// Скорлупа сохраняется после первого создания. Генератор — только настоящий
// ванильный конвейер, захваченный при создании основного мира.
namespace ShatteredGenesis.Content.Subworlds
{
    public class DecoyWorld : Subworld
    {
        public override int Width => 3200;
        public override int Height => 1200;

        public const int LightWallInset = 24;
        public static int LightWallTileX => 3200 - LightWallInset;

        // КАНОН 0.4.26-4 «Вечная Скорлупа» (указ режиссёра): обманка
        // СОЗДАЁТСЯ ОДИН РАЗ и навсегда прикреплена к своему основному миру —
        // библиотека держит её файлы рядом с сейвом мира. Никакой
        // перегенерации при заходах: кольца «бесконечного создания» нет.
        // Живёт до ивента Вселения — там её печать вскроется
        // (WorldFlagsSystem.DestroyShell).
        // До Касания Стены Скорлупа честно сохраняется часами. После
        // Вселения флаги делают её временной и запрещают повторную запись.
        public override bool ShouldSave =>
            !WorldFlagsSystem.InfusionDone && !WorldFlagsSystem.ShellDestroyed;

        // Кнопка возврата Subworld Library ведёт прямо в главное меню,
        // а не загружает основной мир промежуточным кадром. Сюжетный финал
        // остаётся внутри Скорлупы и это назначение вообще не вызывает.
        public override int ReturnDestination => int.MinValue;

        static string pendingDeletionWorldPath;
        static bool pendingDeletionIsCloud;

        // Ведём себя как обычный мир (солнце, жидкости, НПС).
        public override bool NormalUpdates => true;

        // Чертёж для Subworld Library: перед генерацией библиотека спрашивает
        // Config — отдаём ванильный WorldGenConfiguration из слепка, и она
        // сама кормит каждого прохода его персональным куском чертежа.
        public override WorldGenConfiguration Config => VanillaGenSnapshot.Config;

        public override List<GenPass> Tasks
        {
            get
            {
                // Единственный законный режим: полный список ванильных проходов
                // вместе с оригинальным WorldGenConfiguration. Старого ручного
                // генератора и скрытого переключателя больше нет.
                List<GenPass> tasks = VanillaGenSnapshot.BuildTaskList(null);
                tasks.Add(new PassLegacy("…воздвигает край света…",
                    GenerateLightWall, 8f));
                tasks.Add(new PassLegacy("…готовит привал…",
                    PlaceSceneSpawn, 5f));
                return tasks;
            }
        }

        // ===================== ЭКРАН ПЕРЕНОСА (канон 0.4.26) =====================
        // Subworld Library зовёт DrawSetup/DrawMenu каждый кадр, пока ЭТОТ
        // подмир строится (или читается с диска). Дефолтный DrawMenu просто
        // писал ванильный Main.statusText по центру — ту самую «Настройку
        // жидкостей… 34%», что режиссёр поймал на скрине.
        //
        // 0.4.26 «Перенос»: экран — это КАТСЦЕНА ХОДЬБЫ ПО ЛЕСУ. Широкая
        // лента-силуэт леса (ForestWalk.png) натянута на высоту кадра и
        // ПОЛЗЁТ вбок с походочной тряской; сцена временно немая.
        // курсор на загрузке СКРЫТ (указ №4) — вернёт его вход в подмир.
        //
        // Мы экран ПОДМЕНЯЕМ ЦЕЛИКОМ: своя чернь, свой арт (костёр сна),
        // свои строки-«шёпот» по таймеру и живые угольки. Отдельного
        // update-хука у экрана загрузки нет — DrawMenu одновременно и
        // «часы», и кисть (~60 вызовов в секунду), поэтому таймер диалога
        // крутится прямо внутри неё.

        // КАНОН 0.4.26-3 (аудит №1): ручной счётчик кадров в отрисовке
        // УНИЧТОЖЕН — на 144Гц мониторах он ускорял тайминги катсцены.
        // Вместо него аппаратные часы движка Main.GameUpdateCount:
        // тикает ровно 60 раз в секунду на ЛЮБОЙ герцовке и FPS.
        static uint clockStart;      // GameUpdateCount на первом кадре показа
        static bool clockStarted;    // защёлка «часы заведены»

        // Тики текущего показа экрана (1 тик = строго 1/60 сек, всегда).
        static int LoadTicks
        {
            get
            {
                // Первый кадр показа «прилипает» к часам движка.
                if (!clockStarted)
                {
                    clockStarted = true;
                    clockStart = Main.GameUpdateCount;
                }
                return (int)(Main.GameUpdateCount - clockStart);
            }
        }

        // Пелена загрузки: сам шейдер кэширован в StoryDirector (аудит №3).
        // Здесь — только «предохранитель рисовки»: если Begin с эффектом
        // один раз упал — пелену больше не рисуем (лес и чернь остаются).
        static bool veilBroken;

        // Арт грузим лениво и ОДИН раз — и только если файл существует
        // (урок 0.4.21.1: слепой Request отсутствующего ассета на новых
        // сборках tML выкидывает фатальную модалку даже из-под try/catch).
        static bool loadArtTried;
        static Texture2D loadArt;

        // Сценарий экрана: (с какого тика вплывает строка, текст).
        // ПАУЗЫ В ТИКАХ: 60 тиков ≈ 1 секунда. Тон — дорога к привалу;
        // последняя строка предвещает CampArrive («давай тут остановимся»).
        static readonly (int at, string text)[] LoadScript = new (int, string)[]
        {
            (30,  "…идём потихоньку. Ночь густая — глаз выколи…"),
            (380, "…за деревьями шуршит. Не зверь — ветер. Наверное…"),
            (760, "…погодь, впереди просвет. Там поляна — привал устроим…"),
        };

        // Угольки над «костром сна»: автономная мини-система частиц этого
        // экрана. Свой Random — генерацию мира не сдвигаем.
        const int LoadEmberCount = 26;
        static float[] emberX, emberY, emberV, emberLife;
        static readonly Random emberRand = new Random();

        // Полный перехват отрисовки экрана подмира: копия дефолтного
        // DrawSetup библиотеки, но БЕЗ курсора — кино не должно «светить
        // мышью» посреди сна.
        // БРОНЯ (урок 0.4.25.2, фатальный вылет у режиссёра): в сборке
        // 2026.6 движок может звать меню-отрисовку ВНУТРИ уже открытого
        // spriteBatch — повторный Begin кидает InvalidOperationException
        // «Begin has been called before End». Ловим и рисуем в чужой
        // батч: картинка жива, вылета нет. Свой Begin — закрываем,
        // чужой — не трогаем.
        public override void DrawSetup(GameTime gameTime)
        {
            PlayerInput.SetZoom_UI();

            Main.instance.GraphicsDevice.Clear(Color.Black); // глухая чернь-основа

            // СЕГМЕНТ 1: лесная катсцена (арт + угли + печать) — альфа-батч
            // в UI-пространстве.
            DrawSegment(() => DrawMenu(gameTime));

            // СЕГМЕНТ 2: пелена ночи — один экранный проход шейдером
            // Luminance (SleepVeil). Никаких полосатых прямоугольников:
            // квад натянут на ВЕСЬ физический вьюпорт, поэтому ни при
            // каком UI-масштабе не остаётся ни одной светлой полоски.
            float artAlpha = Math.Min(1f, LoadTicks / 45f);
            DrawVeil(artAlpha);

            // СЕГМЕНТ 3: шёпот дороги поверх пелены.
            DrawSegment(() => DrawLoadText());

            // Указ №4 (0.4.26): курсор на загрузке СКРЫТ — тут ничего не
            // кликают. Его вернёт вход в подмир (AnomalyPlayer/OnEnter).
        }

        // Законный конвой любого батч-сегмента этого экрана (урок 0.4.25.2:
        // движок может звать нас из-под ЧУЖОГО открытого SpriteBatch —
        // повторный Begin тогда кидает InvalidOperationException).
        // Сегмент сгорает целиком, рисуем сквозь чужой батч; свой Begin
        // закрываем, чужой не трогаем никогда.
        static void DrawSegment(Action draw)
        {
            bool ourBatch = true;
            try
            {
                Main.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
                    SamplerState.LinearClamp, DepthStencilState.None,
                    Main.Rasterizer, null, Main.UIScaleMatrix);
            }
            catch (InvalidOperationException)
            {
                ourBatch = false;
            }

            draw();

            if (ourBatch)
                Main.spriteBatch.End();
        }

        // ПЕЛЕНА НОЧИ через Luminance ShaderManager.
        // Шейдеру нужен СВОЙ эффект в Begin — отдельный immediate-сегмент
        // в ФИЗИЧЕСКИХ пикселях (Matrix.Identity + полный вьюпорт).
        static void DrawVeil(float artAlpha)
        {
            if (veilBroken)
                return;

            // КЭШ (аудит №3): ссылку на шейдер выдал StoryDirector после
            // загрузки эффектов Luminance. Здесь — ни одного словарного
            // поиска: только готовая ссылка и чистая рисовка.
            var veil = StoryDirector.VeilShader;
            if (veil == null)
                return; // шейдер ещё компилится — есть чернь и лес

            var gd = Main.instance.GraphicsDevice;
            try
            {
                veil.TrySetParameter("screenSize",
                    new Vector2(gd.Viewport.Width, gd.Viewport.Height));
                veil.TrySetParameter("opacity", artAlpha);
                veil.TrySetParameter("globalTime", Main.GlobalTimeWrappedHourly);

                // Immediate + наш эффект, в ФИЗИЧЕСКИХ пикселях: один квад
                // на весь вьюпорт — идеальная укрытость при любом UI-скейле.
                Main.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend,
                    SamplerState.LinearClamp, DepthStencilState.None,
                    RasterizerState.CullNone, veil.WrappedEffect, Matrix.Identity);
                Main.spriteBatch.Draw(TextureAssets.MagicPixel.Value,
                    new Rectangle(0, 0, gd.Viewport.Width, gd.Viewport.Height), Color.White);
                Main.spriteBatch.End();
            }
            catch (Exception)
            {
                // Шейдер в бою оказался бракованным — отключаем навсегда,
                // игру не роняем. (Батч движка на этой мине не пострадает:
                // End либо выполнился, либо Begin не открывался.)
                veilBroken = true;
            }
        }

        // Вызывается DrawSetup на каждом кадре стройки подмира.
        // base.DrawMenu НЕ зовём нарочно: именно в нём живёт ванильный
        // статус-текст. Не позвали — ванили на экране нет совсем.
        public override void DrawMenu(GameTime gameTime)
        {
            // Канон 0.4.25: статус-текст больше НЕ глушим — ванильный
            // источник на этом экране не вызывается вообще (base не зовём),
            // гасить нечего. Кадр целиком наш. ВРЕМЯ — аппаратное: часы
            // движка GameUpdateCount (аудит №1), а не счёт кадров отрисовки.
            int t = LoadTicks;

            TryLoadArt();

            SpriteBatch sb = Main.spriteBatch;

            // Геометрия кадра — движковая (аудит №2): UI-трансформ уже в
            // батче (SetZoom_UI в DrawSetup), размеры читаем float'ами из
            // системных же полей — без ручных «+1 на глаз».
            UiFrame(out float sw, out float sh);

            // АРТ вплывает из черни за ~0.75 сек (45 аппаратных тиков).
            // Меняй сюда, если захочется резче/мягче.
            float artA = Math.Min(1f, t / 45f);

            if (loadArt != null)
            {
                // ЛЕСНАЯ ЛЕНТА (канон 0.4.26): натянута по высоте кадра,
                // шире кадра — есть ход для панорамы. Лента ползёт вбок
                // медленным косинусом, вертикаль пружинит («шаговая
                // тряска»). Рисуем ЧИСТЫМИ float: точную обрезку кадра
                // делает GPU, поэтому ни одной микро-щели по краям —
                // ни при растяжении, ни при смене UI-масштаба.
                float stripH = sh;
                float stripW = loadArt.Width * (stripH / loadArt.Height);
                float range = Math.Max(0f, stripW - sw);
                float travel = 0.5f - 0.5f * (float)Math.Cos(t * 0.004f); // 0..1, цикл ~26 сек
                float ox = travel * range;
                float bob = (float)Math.Sin(t * 0.13f) * 3f;

                sb.Draw(loadArt, new Vector2(-ox, bob), null, Color.White * artA,
                    0f, Vector2.Zero,
                    new Vector2(stripW / loadArt.Width, stripH / loadArt.Height),
                    SpriteEffects.None, 0f);
            }

            DrawLoadEmbers(sb, sw, sh);     // угольки летят над сном
            StoryDirector.DrawStamp(sb, sw, sh); // печать сборки (урок 0.4.25.3)
        }

        // СЕГМЕНТ ШЁПОТА (канон 0.4.26-2): строки дороги кладутся ПОСЛЕ
        // пелены, чтобы виньетка их не душила (порядок сегментов задан
        // в DrawSetup). Свой батч — собственный, через DrawSegment.
        static void DrawLoadText()
        {
            SpriteBatch sb = Main.spriteBatch;
            UiFrame(out float sw, out float sh); // движковая геометрия (аудит №2)
            int t = LoadTicks;                   // аппаратные часы (аудит №1)

            // ---- ДИАЛОГ ПО ТАЙМЕРУ ----
            // Текущая строка = последняя, чей тик уже наступил. Строки не
            // стекают в чат и не гаснут: шёпот сменяется на экране сам.
            string line = null;
            int lineAt = 0;
            foreach (var (at, text) in LoadScript)
            {
                if (t >= at)
                {
                    line = text;
                    lineAt = at;
                }
            }

            if (line == null)
                return; // первые полсекунды — чистая чернь с угольками

            // ПРОЗРАЧНОСТЬ СТРОКИ: 40 аппаратных тиков на плавный вплыв.
            float a = Math.Min(1f, (t - lineAt) / 40f);

            // Последняя строка после вплыва «дышит» бесконечно — экран
            // живой, сколько бы мир ни строился (быстро/медленно — неважно).
            int lastAt = LoadScript[LoadScript.Length - 1].at;
            if (lineAt == lastAt && t - lineAt > 90)
            {
                a *= 0.72f + 0.28f * (float)Math.Sin((t - lineAt - 90) * 0.04f);
            }

            DrawCentered(sb, line, sh * 0.80f, a, sw);
        }

        // КАНОН 0.4.26-3 (аудит №2): LogicalScreen с «+1 на глаз» УНИЧТОЖЕН.
        // Геометрию кадра диктуют системные поля движка, читаемые в float:
        // вьюпорт делится на тот же Main.UIScale, что движок сам вкладывает
        // в трансформ батча (PlayerInput.SetZoom_UI в DrawSetup). Никакого
        // округления — никаких микро-щелей: точную обрезку делает GPU.
        static void UiFrame(out float sw, out float sh)
        {
            var vp = Main.instance.GraphicsDevice.Viewport;
            float ui = Main.UIScale;
            if (ui <= 0.0001f)
                ui = 1f; // предохранитель от нуля, а не «запас на пиксель»
            sw = vp.Width / ui;
            sh = vp.Height / ui;
        }

        // Ленивая загрузка арта экрана переноса (один пробный заход).
        static void TryLoadArt()
        {
            if (loadArtTried)
                return;
            loadArtTried = true;

            if (!ModAssetHelper.Exists("Assets/Slides/ForestWalk.png"))
                return; // арта нет — экран идёт строками на черни, не беда

            try
            {
                loadArt = ModContent.Request<Texture2D>(
                    "ShatteredGenesis/Assets/Slides/ForestWalk",
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                loadArt = null; // бронежилет: без арта экран всё равно работает
            }
        }

        // Строка-шёпот по центру: фирменный янтарь + чёрная кайма по четырём
        // осям — читается и поверх арта. scale подобран под DeathText.
        static void DrawCentered(SpriteBatch sb, string text, float y, float alpha, float sw)
        {
            var font = FontAssets.DeathText.Value;
            const float scale = 0.42f;

            Vector2 size = font.MeasureString(text) * scale;
            Vector2 pos = new Vector2(
                sw * 0.5f - size.X * 0.5f, y - size.Y * 0.5f);

            Color shade = Color.Black * (alpha * 0.9f);
            sb.DrawString(font, text, pos + new Vector2(2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(-2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, 2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, -2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);

            sb.DrawString(font, text, pos,
                new Color(255, 196, 60) * alpha, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
        }

        // [УНИЧТОЖЕНО 0.4.26-2] DrawVignette-полосками: 24 прямоугольника
        // MagicPixel с запасами — как раз тот «поверхностный костыль»,
        // что запретил режиссёр. Виньетка теперь — шейдер SleepVeil через
        // Luminance (сегмент 2 в DrawSetup): один точный проход по кругу,
        // ни одной светлой полосы при любом UI-масштабе.

        // Угольки: всплывают из «очага» в центральном низу экрана, гуляют
        // синусом, догорают рубиновым и рождаются снова. Тикают в DrawMenu.
        static void DrawLoadEmbers(SpriteBatch sb, float sw, float sh)
        {
            if (emberX == null)
            {
                emberX = new float[LoadEmberCount];
                emberY = new float[LoadEmberCount];
                emberV = new float[LoadEmberCount];
                emberLife = new float[LoadEmberCount];
                for (int i = 0; i < LoadEmberCount; i++)
                    RespawnEmber(i, sw, sh, true); // первый раз — уже в воздухе
            }

            Texture2D px = TextureAssets.MagicPixel.Value;
            for (int i = 0; i < LoadEmberCount; i++)
            {
                emberLife[i] -= 0.005f;   // один уголёк живёт ≈ 3 секунды
                emberY[i] -= emberV[i];   // всегда вверх, над остывающим сном
                emberX[i] += (float)Math.Sin((LoadTicks + i * 13) * 0.05) * 0.4f;

                if (emberLife[i] <= 0f || emberY[i] < sh * 0.08f)
                    RespawnEmber(i, sw, sh, false);

                float life = Math.Max(0f, Math.Min(1f, emberLife[i]));
                // Живой кал → тлеющий рубин (по мере угасания), как в сцене.
                Color c = Color.Lerp(new Color(196, 90, 20),
                                     new Color(255, 226, 140), life) * (life * 0.85f);
                sb.Draw(px, new Rectangle((int)emberX[i], (int)emberY[i], 3, 3), c);
            }
        }

        static void RespawnEmber(int i, float sw, float sh, bool anywhere)
        {
            // Очаг — центральный низ экрана: там, где в арте стоит костёр.
            emberX[i] = sw * 0.38f + (float)emberRand.NextDouble() * sw * 0.24f;
            emberY[i] = anywhere
                ? sh * (0.30f + (float)emberRand.NextDouble() * 0.50f)
                : sh * (0.78f + (float)emberRand.NextDouble() * 0.12f);
            emberV[i] = 0.6f + (float)emberRand.NextDouble() * 1.2f;
            emberLife[i] = 0.4f + (float)emberRand.NextDouble() * 0.6f;
        }

        // Ручные этапы 0.3.4.1 удалены в 0.4.26.7. Скорлупу строит
        // только захваченный конвейер самой Terraria.

        // ===================== Стена Света на правом краю =====================
        private void GenerateLightWall(GenerationProgress progress, GameConfiguration config)
        {
            int wallType = ModContent.TileType<LightWallTile>();
            int wallX = LightWallTileX;

            for (int y = 5; y < Height - 5; y++)
            {
                WorldGen.KillTile(wallX, y, false, false, true);
                WorldGen.PlaceTile(wallX, y, wallType, true, true);

                if (y % 100 == 0)
                    progress.Set(y / (double)Height);
            }
            progress.Set(1.0);
        }

        static void EnsureLightWallExists()
        {
            if (WorldFlagsSystem.InfusionDone || WorldFlagsSystem.ShellDestroyed)
                return;

            int wallType = ModContent.TileType<LightWallTile>();
            int wallX = LightWallTileX;
            for (int y = 5; y < Main.maxTilesY - 5; y++)
            {
                Tile tile = Framing.GetTileSafely(wallX, y);
                if (!tile.HasTile || tile.TileType != wallType)
                {
                    WorldGen.KillTile(wallX, y, false, false, true);
                    WorldGen.PlaceTile(wallX, y, wallType, true, true);
                }
            }
        }

        // ===================== Финиш: точка появления и площадка сцены =====================
        private void PlaceSceneSpawn(GenerationProgress progress, GameConfiguration config)
        {
            int spawnX = Width / 2;
            int surfaceY = FindSurfaceY(spawnX);

            Main.spawnTileX = spawnX;
            Main.spawnTileY = surfaceY - 3;

            // Физическая 3x2 структура привала вшивается прямо в Main.tile.
            // Origin костра — нижняя центральная клетка (spawnX, surfaceY-1).
            for (int x = spawnX - 4; x <= spawnX + 4; x++)
            {
                for (int y = surfaceY - 5; y < surfaceY; y++)
                    WorldGen.KillTile(x, y, false, false, true);

                if (!Framing.GetTileSafely(x, surfaceY).HasTile)
                    WorldGen.PlaceTile(x, surfaceY, TileID.Dirt, true, true);
            }

            bool campfirePlaced = WorldGen.PlaceObject(
                spawnX,
                surfaceY - 1,
                ModContent.TileType<StoryCampfireTile>(),
                true);

            if (!campfirePlaced)
                Mod.Logger.Warn("[SG] StoryCampfireTile не встал при генерации Скорлупы; " +
                    "CampfireScene повторит размещение при входе.");

            // Гида здесь больше не создаём: в сцене сначала только игрок и
            // Сказитель. Настоящий NPCID.Guide приходит из темноты на узле 20.
            progress.Set(1.0);
        }

        // ===================== Помощники =====================
        private int FindSurfaceY(int x)
        {
            for (int y = 0; y < Height; y++)
            {
                if (Main.tile[x, y].HasTile && Main.tileSolid[Main.tile[x, y].TileType])
                    return y;
            }
            return (int)Main.worldSurface;
        }

        public static void PreparePermanentDestruction()
        {
            if (!SubworldSystem.IsActive<DecoyWorld>())
                return;

            pendingDeletionWorldPath = SubworldSystem.CurrentPath;
            pendingDeletionIsCloud = Main.ActiveWorldFileData?.IsCloudSave ?? false;
        }

        public static void FinalizePendingDestruction()
        {
            if (string.IsNullOrWhiteSpace(pendingDeletionWorldPath))
                return;

            string worldPath = pendingDeletionWorldPath;
            string dataPath = Path.ChangeExtension(worldPath, ".twld");
            string[] paths =
            {
                worldPath, worldPath + ".bak", worldPath + ".bad",
                dataPath, dataPath + ".bak", dataPath + ".bad"
            };

            foreach (string path in paths)
            {
                try
                {
                    if (FileUtilities.Exists(path, pendingDeletionIsCloud))
                        FileUtilities.Delete(path, pendingDeletionIsCloud);
                }
                catch (Exception exception)
                {
                    ModContent.GetInstance<global::ShatteredGenesis.ShatteredGenesis>().Logger.Warn(
                        $"[SG] Не удалось удалить файл Скорлупы '{path}': " + exception.Message);
                }
            }

            pendingDeletionWorldPath = null;
            pendingDeletionIsCloud = false;
        }

        public override void OnLoad()
        {
            EnsureLightWallExists();

            // OnLoad вызывается библиотекой после чтения/создания файла
            // Скорлупы. Сцена всегда начинается в одной и той же глубокой ночи.
            Main.dayTime = false;
            Main.time = 16200;
            Main.raining = false;
            Main.windSpeedCurrent = 0f;
            Main.windSpeedTarget = 0f;
        }

        public override void OnEnter()
        {
            // Переход начался: занавес перелёта опускаем (его поднимал
            // AnomalyPlayer перед броском сюда — канон 0.4.26).
            StoryDirector.LiftCurtain();

            clockStarted = false; // лесная катсцена отработала — аппаратные
                                  // часы следующего показа стартуют заново

            // Курсор вернёт AnomalyPlayer (требование №4); здесь — дубль-
            // страховка, дёшевая.
            Main.cursorScale = 1f;

            // Баг №3 (канон 0.4.24): на спавне не терпим двух Гидов.
            WorldFlagsSystem.DedupeGuides();

            // Звук здесь НЕ возвращаем: Subworld Library вызывает OnEnter
            // ещё до чтения/создания файла подмира. Восстановление выполняет
            // AnomalyPlayer.OnEnterWorld после полной загрузки Скорлупы.

            // ВРЕМЯ ТУТ БОЛЬШЕ НЕ СТАВИМ: сцену ночи назначает
            // CampfireScene.Begin, а первое утро — её Finish (0.4.26).

        }
    }
}

================================================================
=== ФАЙЛ: Content/Tiles/CampLog.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.DataStructures;
using Terraria.Enums;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ObjectData;

namespace ShatteredGenesis.Content.Tiles
{
    // Полноценное ветхое бревно 4x1. Это настоящий multitile с общей опорой,
    // на котором PlayerSittingHelper и NPC.SitDown используют нативную посадку.
    public sealed class CampLog : ModTile
    {
        public const int Width = 4;
        public const int Height = 1;

        public override void SetStaticDefaults()
        {
            Main.tileFrameImportant[Type] = true;
            Main.tileNoAttach[Type] = true;
            Main.tileLavaDeath[Type] = false;
            TileID.Sets.CanBeSatOnForPlayers[Type] = true;
            TileID.Sets.CanBeSatOnForNPCs[Type] = true;
            TileID.Sets.DisableSmartCursor[Type] = true;

            DustType = DustID.WoodFurniture;
            AdjTiles = new int[] { TileID.Chairs };
            AddMapEntry(new Color(76, 43, 22),
                Language.GetText("MapObject.Chair"));

            TileObjectData.newTile.CopyFrom(TileObjectData.Style1x1);
            TileObjectData.newTile.Width = Width;
            TileObjectData.newTile.Height = Height;
            TileObjectData.newTile.Origin = new Point16(1, 0);
            TileObjectData.newTile.CoordinateWidth = 16;
            TileObjectData.newTile.CoordinatePadding = 2;
            TileObjectData.newTile.CoordinateHeights = new int[] { 16 };
            TileObjectData.newTile.AnchorBottom = new AnchorData(
                AnchorType.SolidTile | AnchorType.SolidWithTop,
                Width,
                0);
            TileObjectData.addTile(Type);
        }

        public override void ModifySittingTargetInfo(int i, int j,
            ref TileRestingInfo info)
        {
            Tile tile = Framing.GetTileSafely(i, j);
            int left = i - tile.TileFrameX / 18 % Width;

            // Центр четырёхклеточного бревна находится между двумя средними
            // сегментами. Anchor ставим на левый из них, смещение доводит
            // персонажа до геометрического центра.
            bool leftOfFire = left + Width / 2 < Main.spawnTileX;
            info.TargetDirection = leftOfFire ? 1 : -1;
            info.AnchorTilePosition = new Point(
                leftOfFire ? left + 1 : left + 2,
                j);
            info.DirectionOffset = 8;
            info.VisualOffset = new Vector2(0f, 3f);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Tiles/LightWallTile.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

namespace ShatteredGenesis.Content.Tiles
{
    // Неразрушимая вертикальная граница Скорлупы. Стена является обычным
    // сохранённым тайлом мира, но ни кирка, ни бур, ни взрыв её не изменяют.
    public sealed class LightWallTile : ModTile
    {
        public override void SetStaticDefaults()
        {
            Main.tileSolid[Type] = true;
            Main.tileBlockLight[Type] = false;
            Main.tileLighted[Type] = true;
            Main.tileFrameImportant[Type] = false;
            Main.tileNoAttach[Type] = true;
            TileID.Sets.DisableSmartCursor[Type] = true;

            MineResist = float.MaxValue;
            MinPick = int.MaxValue;
            DustType = DustID.GoldFlame;
            AddMapEntry(new Color(255, 190, 54));
        }

        public override bool CanKillTile(int i, int j, ref bool blockDamaged)
        {
            blockDamaged = false;
            LightWallEvent.NotifyWallHit(i, j);
            return false;
        }

        public override bool CanExplode(int i, int j) => false;

        public override void KillTile(int i, int j,
            ref bool fail, ref bool effectOnly, ref bool noItem)
        {
            fail = true;
            effectOnly = true;
            noItem = true;
            LightWallEvent.NotifyWallHit(i, j);
        }

        public override void ModifyLight(int i, int j,
            ref float r, ref float g, ref float b)
        {
            float pulse = 0.88f + 0.12f *
                (float)System.Math.Sin(Main.GlobalTimeWrappedHourly * 3.2f + j * 0.08f);
            r = 1.10f * pulse;
            g = 0.68f * pulse;
            b = 0.16f * pulse;
        }
    }
}

================================================================
=== ФАЙЛ: Content/Tiles/StoryCampfireTile.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ObjectData;

namespace ShatteredGenesis.Content.Tiles
{
    // Физический сюжетный костёр. Это настоящий 3x2 multitile, который
    // сохраняется внутри файла Скорлупы и анимируется штатным TileDrawing.
    public sealed class StoryCampfireTile : ModTile
    {
        public const int FrameHeight = 36;
        public const int AnimationFrames = 4;

        public override void SetStaticDefaults()
        {
            Main.tileLighted[Type] = true;
            Main.tileFrameImportant[Type] = true;
            Main.tileWaterDeath[Type] = true;
            Main.tileLavaDeath[Type] = true;
            TileID.Sets.Campfire[Type] = true;
            TileID.Sets.DisableSmartCursor[Type] = true;

            DustType = -1;
            AdjTiles = new int[] { TileID.Campfire };
            AddMapEntry(new Color(242, 103, 16),
                Language.GetText("ItemName.Campfire"));

            TileObjectData.newTile.CopyFrom(
                TileObjectData.GetTileData(TileID.Campfire, 0));
            TileObjectData.newTile.StyleLineSkip = AnimationFrames;
            TileObjectData.addTile(Type);
        }

        public override void AnimateTile(ref int frame, ref int frameCounter)
        {
            if (++frameCounter >= 6)
            {
                frameCounter = 0;
                frame = (frame + 1) % AnimationFrames;
            }
        }

        public override void AnimateIndividualTile(int type, int i, int j,
            ref int frameXOffset, ref int frameYOffset)
        {
            frameYOffset = Main.tileFrame[type] * FrameHeight;
        }

        public override void NearbyEffects(int i, int j, bool closer)
        {
            if (!closer)
                Main.SceneMetrics.HasCampfire = true;
        }

        public override void ModifyLight(int i, int j,
            ref float r, ref float g, ref float b)
        {
            float pulse = 0.92f + 0.08f *
                (float)System.Math.Sin(Main.GameUpdateCount * 0.17f);
            r = 1.15f * pulse;
            g = 0.42f * pulse;
            b = 0.08f * pulse;
        }
    }
}

================================================================
=== ФАЙЛ: ShatteredGenesis.cs
================================================================
using Terraria.ModLoader;

// Это главный класс мода — его «сердце».
// tModLoader находит этот класс сам: он должен называться так же, как папка мода,
// и наследоваться от Mod.
//
// Пока он пустой — и это нормально: вся настоящая работа делается
// в других файлах (системы, игроки, предметы). Сюда мы вернёмся,
// когда нужно будет регистрировать глобальные вещи мода.
namespace ShatteredGenesis
{
    public class ShatteredGenesis : Mod
    {
    }
}

================================================================
=== ФАЙЛ: ShatteredGenesis.csproj
================================================================
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">

  <!-- Это файл проекта. Не редактируй его — tModLoader сам всё сделает через него.
       Важно: строка Import указывает на файл tModLoader.targets, который появляется
       в папке ModSources после первого запуска tModLoader. -->
  <Import Project="..\tModLoader.targets" />

  <PropertyGroup>
    <!-- Внутреннее имя мода — так его видит игра. Меняется только переименованием папки. -->
    <AssemblyName>ShatteredGenesis</AssemblyName>
    <RootNamespace>ShatteredGenesis</RootNamespace>
    <!-- Современный tModLoader работает на .NET 8. Если при сборке будет ошибка
         про версию net8.0 — напиши мне, подгоню под твою версию tML. -->
    <TargetFramework>net8.0</TargetFramework>
    <PlatformTarget>AnyCPU</PlatformTarget>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

</Project>

================================================================
=== ФАЙЛ: build.txt
================================================================
displayName = Shattered Genesis — Расколотый Первомир
author = Shattered Genesis Team
version = 0.4.26.20
# Жёсткие зависимости (указ режиссёра 0.4.26-2): библиотеки на полную мощность.
# Luminance — шейдеры/фильтры/частицы; DAYBREAK — слой системных хуков;
# SubworldLibrary — подмиры. Без них игра откажется грузить мод — и это правильно.
modReferences = Luminance, Daybreak, SubworldLibrary

================================================================
=== ФАЙЛ: description.txt
================================================================
Ты думал, что начал обычную игру.
Ты рубил деревья, разговаривал с Гидом, слушал птиц.

А потом увидел свет у края мира — и коснулся его.

Shattered Genesis — тотальная конверсия: новый мир, новая история,
двенадцать тронов и тайна, которую не знает никто.

Ранняя сборка 0.1 — каркас мира. Содержимое появляется глава за главой.