diff --git a/.github/ISSUE_TEMPLATE/08-glass-bug.yml b/.github/ISSUE_TEMPLATE/08-glass-bug.yml new file mode 100644 index 0000000..bb7cc2c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/08-glass-bug.yml @@ -0,0 +1,126 @@ +name: GLASS Remastered bug report +description: Report a crash, broken behaviour, or compatibility problem in GLASS Remastered. +title: "[GLASS] " +labels: + - "project:glass" +type: Bug +body: + - type: markdown + attributes: + value: | + Report a problem with GLASS Remastered. Tell us what happened and how we can reproduce it. If you are unsure about something, say so. + + - type: markdown + attributes: + value: | + If you are on a game version or loader not listed here, we are unfortunately not supporting it anymore. + + - type: checkboxes + id: existing-issues + attributes: + label: Existing issues + options: + - label: I have [searched the existing issues](https://github.com/IMB11-Mods/JavaEditionMods/issues?q=is%3Aissue%20label%3A%22project%3Aglass%22) for this problem or suggestion. + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the problem and what you expected to happen instead. Include the exact error message, if there is one. + placeholder: | + What happened: + What I expected: + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: How can we reproduce it? + description: List the steps that trigger the problem. If it happens unpredictably, describe what you were doing and how often it happens. + placeholder: | + 1. Start the game with... + 2. Open or do... + 3. The problem happens when... + validations: + required: true + + - type: dropdown + id: minecraft-version + attributes: + label: Minecraft version + description: Which Minecraft version were you using when the problem happened? + options: + - "26.1.2" + validations: + required: true + + - type: dropdown + id: mod-loader + attributes: + label: Mod loader + description: Which mod loader are you using? + options: + - Fabric + - NeoForge + validations: + required: true + + - type: input + id: mod-version + attributes: + label: GLASS Remastered version + description: Copy the exact version from your launcher's mod list or the mod's filename. Please avoid "latest". + validations: + required: true + + - type: dropdown + id: environment + attributes: + label: Where does it happen? + options: + - Starting the game + - Singleplayer + - Playing on a server + - Running a dedicated server + - Development/build + - Other / Not sure + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or crash report + description: | + Open the affected game instance's folder in your launcher and find logs/latest.log. Upload it to [mclo.gs](https://mclo.gs/) and paste the link here, even if the game did not crash. + If the game crashed, include the matching report from the crash-reports folder too. For development issues, include the build output instead. + Review logs for personal information before sharing them. If logs are unavailable, explain why. + placeholder: Paste your log links or explain why logs are unavailable. + validations: + required: true + + - type: textarea + id: other-mods-and-settings + attributes: + label: Other mods, packs, or changed settings + description: Include relevant mods and versions, a modpack link and version, resource packs, or settings you changed. + validations: + required: false + + - type: textarea + id: troubleshooting + attributes: + label: What have you tried? + description: Mention anything you tested and whether it helped. If you tested with only this mod and its required dependencies, tell us the result. You do not need to do this before reporting. + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Screenshots, videos, or extra details + description: Attach anything that helps demonstrate the problem. For development issues, include a small code example or example project if possible. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/09-glass-feature.yml b/.github/ISSUE_TEMPLATE/09-glass-feature.yml new file mode 100644 index 0000000..10d743b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/09-glass-feature.yml @@ -0,0 +1,85 @@ +name: GLASS Remastered feature request +description: Suggest a new feature or improvement for GLASS Remastered. +title: "[GLASS] " +labels: + - "project:glass" +type: Feature +body: + - type: markdown + attributes: + value: | + Suggest an improvement for GLASS Remastered. Explain what you want to achieve and how the change would help. + + - type: markdown + attributes: + value: | + If you are on a game version or loader not listed here, we are unfortunately not supporting it anymore. + + - type: checkboxes + id: existing-issues + attributes: + label: Existing issues + options: + - label: I have [searched the existing issues](https://github.com/IMB11-Mods/JavaEditionMods/issues?q=is%3Aissue%20label%3A%22project%3Aglass%22) for this problem or suggestion. + required: true + + - type: textarea + id: use-case + attributes: + label: What would this help you do? + description: Describe the problem, missing capability, or situation you want to improve. A concrete example helps. + validations: + required: true + + - type: textarea + id: proposed-feature + attributes: + label: What would you like to happen? + description: Describe how you would expect the feature to work. You do not need to explain how to implement it. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives or workarounds + description: Is there another way to achieve this today? What does not work well about it? + validations: + required: false + + - type: dropdown + id: minecraft-version + attributes: + label: Minecraft version + description: Select a version only if your request is specific to it. + options: + - "26.1.2" + validations: + required: false + + - type: dropdown + id: mod-loader + attributes: + label: Mod loader + description: Select a loader only if your request is specific to it. + options: + - Fabric + - NeoForge + validations: + required: false + + - type: textarea + id: related-mods + attributes: + label: Relevant mod versions or other mods + description: Include GLASS Remastered or other mod versions only if the request depends on them. + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Examples or additional context + description: Add screenshots, mockups, links, or related requests. + validations: + required: false diff --git a/.github/scripts/changelog-comment.cjs b/.github/scripts/changelog-comment.cjs index b5731c1..a64c83d 100644 --- a/.github/scripts/changelog-comment.cjs +++ b/.github/scripts/changelog-comment.cjs @@ -17,7 +17,7 @@ module.exports = async ({ github, context }) => { const issue_number = pull.number; const repo = { ...context.repo, issue_number }; const comments = await github.paginate(github.rest.issues.listComments, repo); - for (const project of ['sounds', 'mru', 'shields', 'fog']) { + for (const project of ['sounds', 'mru', 'shields', 'fog', 'glass']) { if (!pull.labels.some(label => label.name === `project:${project}`)) continue; const marker = `[release-changelog-${project}]: #`; if (comments.some(comment => comment.user.type === 'Bot' && comment.body.split(/\r?\n/).includes(marker))) continue; diff --git a/.github/scripts/prepare-release.mjs b/.github/scripts/prepare-release.mjs index 6b2b0d0..e49f976 100644 --- a/.github/scripts/prepare-release.mjs +++ b/.github/scripts/prepare-release.mjs @@ -11,7 +11,7 @@ const git = (...values) => run('git', values); const project = option('project'); const version = option('version'); const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -if (!['sounds', 'mru', 'shields', 'fog'].includes(project) || !versionPattern.test(version ?? '')) throw new Error('Provide --project sounds|mru|shields|fog and --version X.Y.Z. Optional: --from REF, --write.'); +if (!['sounds', 'mru', 'shields', 'fog', 'glass'].includes(project) || !versionPattern.test(version ?? '')) throw new Error('Provide --project sounds|mru|shields|fog|glass and --version X.Y.Z. Optional: --from REF, --write.'); const suffix = version.match(/^[^-+]+(?:-([^+]+))?(?:\+(.+))?$/); if ([suffix?.[1], suffix?.[2]].filter(Boolean).some(value => value.split('.').some(part => !part)) || suffix?.[1]?.split('.').some(part => /^0\d+$/.test(part))) throw new Error('Invalid semantic version suffix'); const head = git('rev-parse', 'HEAD'); diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index edb06bb..cd3a369 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -7,7 +7,7 @@ on: description: project type: choice required: true - options: [sounds, mru, shields, fog] + options: [sounds, mru, shields, fog, glass] version: description: version type: string diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76a01d2..4fae4e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: - sounds/v* - shields/v* - fog/v* + - glass/v* workflow_dispatch: inputs: tag: @@ -33,8 +34,8 @@ jobs: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | case "$RELEASE_TAG" in - sounds/v*|mru/v*|shields/v*|fog/v*) ;; - *) echo 'Expected a sounds/v, mru/v, shields/v, or fog/v release tag'; exit 1 ;; + sounds/v*|mru/v*|shields/v*|fog/v*|glass/v*) ;; + *) echo 'Expected a sounds/v, mru/v, shields/v, fog/v, or glass/v release tag'; exit 1 ;; esac test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" echo "RELEASE_MOD=${RELEASE_TAG%%/*}" >> "$GITHUB_ENV" @@ -51,7 +52,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | case "$RELEASE_MOD" in - mru|sounds|shields|fog) task=":$RELEASE_MOD:publishMods" ;; + mru|sounds|shields|fog|glass) task=":$RELEASE_MOD:publishMods" ;; *) exit 1 ;; esac GITHUB_REF_TYPE=tag GITHUB_REF_NAME="$RELEASE_TAG" GITHUB_SHA="$(git rev-parse HEAD)" bash ./gradlew --no-daemon --build-cache "$task" diff --git a/glass/CHANGELOG.md b/glass/CHANGELOG.md new file mode 100644 index 0000000..572b09c --- /dev/null +++ b/glass/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [1.0.0-beta] + +### Added + +- Initial release. +- Support for 26.1.2 Fabric and 26.1.2 NeoForge. diff --git a/glass/LICENSE b/glass/LICENSE new file mode 100644 index 0000000..a2bfcf2 --- /dev/null +++ b/glass/LICENSE @@ -0,0 +1,2 @@ +Copyright (c) 2024 IMB11 +All rights reserved. diff --git a/glass/README.md b/glass/README.md new file mode 100644 index 0000000..9a75310 --- /dev/null +++ b/glass/README.md @@ -0,0 +1,8 @@ +# G.L.A.S.S: Remastered + +## Credits + +- Block models and textures by [thepigcat](https://modrinth.com/user/Thepigcat76). +- User Interface Magic Chimes by mikiko850 -- https://freesound.org/s/849807/ -- License: Attribution 4.0 +- pulse3D.wav by Sclolex -- https://freesound.org/s/172393/ -- License: Creative Commons 0 +- The original General Laymans' Aesthetic Spying Screen mod by iChun, found here: https://www.curseforge.com/minecraft/mc-mods/general-laymans-aesthetic-spying-screen-glass diff --git a/glass/branch.gradle.kts b/glass/branch.gradle.kts new file mode 100644 index 0000000..f8f400d --- /dev/null +++ b/glass/branch.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("imb11.publish-mod") +} diff --git a/glass/build.fabric.gradle.kts b/glass/build.fabric.gradle.kts new file mode 100644 index 0000000..a2e046c --- /dev/null +++ b/glass/build.fabric.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("imb11.fabric-mod") +} + +loom { + accessWidenerPath = project.parent!!.file("src/main/resources/glass.accesswidener") +} + +dependencies { + compileOnly(mcLibrary("sodium-fabric")) + if (providers.gradleProperty("with_sodium").isPresent) { + "localRuntime"(mcLibrary("sodium-fabric")) + } +} + +fabricApi { + configureDataGeneration { + outputDirectory = project.parent!!.file("src/main/generated") + addToResources = false + client = true + } +} + +loom.runs.named("datagen") { + configName = "${modMetadata.required("mod.name")} - ${project.minecraftVersion} - Fabric - Data Generation" + runDir(rootProject.file("run/${modMetadata.id}/${project.minecraftVersion}/fabric/datagen").relativeTo(project.projectDir).invariantSeparatorsPath) +} diff --git a/glass/build.neoforge.gradle.kts b/glass/build.neoforge.gradle.kts new file mode 100644 index 0000000..138ce32 --- /dev/null +++ b/glass/build.neoforge.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("imb11.neoforge-mod") +} + +neoForge { + accessTransformers.from(project.parent!!.file("src/main/resources/META-INF/accesstransformer.cfg")) +} + +// The NeoForge Sodium jar is a jar-in-jar wrapper; compile against the nested mod jar. +val sodiumDistribution = configurations.create("sodiumDistribution") { + isCanBeConsumed = false + isTransitive = false +} + +dependencies { + add(sodiumDistribution.name, mcLibrary("sodium-neoforge")) + compileOnly(files(provider { + sodiumDistribution.files.flatMap { zipTree(it).matching { include("META-INF/jarjar/net.caffeinemc.sodium-*-mod.jar") }.files } + })) + if (providers.gradleProperty("with_sodium").isPresent) { + runtimeOnly(mcLibrary("sodium-neoforge")) + } +} diff --git a/glass/mod.properties b/glass/mod.properties new file mode 100644 index 0000000..c129305 --- /dev/null +++ b/glass/mod.properties @@ -0,0 +1,9 @@ +mod.id=glass +mod.name=GLASS Remastered +mod.description=General Laymans' Aesthetic Spying Screen, remastered for modern Minecraft. +mod.license=All-Rights-Reserved +mod.version=1.0.0-beta +mod.group=dev.imb11.glass +publish.modrinth=5TABPEIf +publish.curseforge=1090535 +publish.dependencies.fabric=fabric-api diff --git a/glass/src/main/generated/assets/glass/blockstates/powerable_glass.json b/glass/src/main/generated/assets/glass/blockstates/powerable_glass.json new file mode 100644 index 0000000..827eb5d --- /dev/null +++ b/glass/src/main/generated/assets/glass/blockstates/powerable_glass.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "glass:block/powerable_glass" + } + } +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/blockstates/projector.json b/glass/src/main/generated/assets/glass/blockstates/projector.json new file mode 100644 index 0000000..a288e6f --- /dev/null +++ b/glass/src/main/generated/assets/glass/blockstates/projector.json @@ -0,0 +1,56 @@ +{ + "variants": { + "facing=down,powered=false": { + "model": "glass:block/projector", + "x": 180 + }, + "facing=down,powered=true": { + "model": "glass:block/projector", + "x": 180 + }, + "facing=east,powered=false": { + "model": "glass:block/projector", + "x": 90, + "y": 270 + }, + "facing=east,powered=true": { + "model": "glass:block/projector", + "x": 90, + "y": 270 + }, + "facing=north,powered=false": { + "model": "glass:block/projector", + "x": 90, + "y": 180 + }, + "facing=north,powered=true": { + "model": "glass:block/projector", + "x": 90, + "y": 180 + }, + "facing=south,powered=false": { + "model": "glass:block/projector", + "x": 90 + }, + "facing=south,powered=true": { + "model": "glass:block/projector", + "x": 90 + }, + "facing=up,powered=false": { + "model": "glass:block/projector" + }, + "facing=up,powered=true": { + "model": "glass:block/projector" + }, + "facing=west,powered=false": { + "model": "glass:block/projector", + "x": 90, + "y": 90 + }, + "facing=west,powered=true": { + "model": "glass:block/projector", + "x": 90, + "y": 90 + } + } +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/blockstates/redstone_infused_sand.json b/glass/src/main/generated/assets/glass/blockstates/redstone_infused_sand.json new file mode 100644 index 0000000..1f071a6 --- /dev/null +++ b/glass/src/main/generated/assets/glass/blockstates/redstone_infused_sand.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "glass:block/redstone_infused_sand" + } + } +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/blockstates/terminal.json b/glass/src/main/generated/assets/glass/blockstates/terminal.json new file mode 100644 index 0000000..660672f --- /dev/null +++ b/glass/src/main/generated/assets/glass/blockstates/terminal.json @@ -0,0 +1,52 @@ +{ + "variants": { + "facing=down,projecting=false": { + "model": "glass:block/terminal", + "x": 270, + "y": 180 + }, + "facing=down,projecting=true": { + "model": "glass:block/terminal_projecting", + "x": 270, + "y": 180 + }, + "facing=east,projecting=false": { + "model": "glass:block/terminal", + "y": 270 + }, + "facing=east,projecting=true": { + "model": "glass:block/terminal_projecting", + "y": 270 + }, + "facing=north,projecting=false": { + "model": "glass:block/terminal", + "y": 180 + }, + "facing=north,projecting=true": { + "model": "glass:block/terminal_projecting", + "y": 180 + }, + "facing=south,projecting=false": { + "model": "glass:block/terminal" + }, + "facing=south,projecting=true": { + "model": "glass:block/terminal_projecting" + }, + "facing=up,projecting=false": { + "model": "glass:block/terminal", + "x": 90 + }, + "facing=up,projecting=true": { + "model": "glass:block/terminal_projecting", + "x": 90 + }, + "facing=west,projecting=false": { + "model": "glass:block/terminal", + "y": 90 + }, + "facing=west,projecting=true": { + "model": "glass:block/terminal_projecting", + "y": 90 + } + } +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/items/powerable_glass.json b/glass/src/main/generated/assets/glass/items/powerable_glass.json new file mode 100644 index 0000000..3abc822 --- /dev/null +++ b/glass/src/main/generated/assets/glass/items/powerable_glass.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "glass:item/powerable_glass" + } +} diff --git a/glass/src/main/generated/assets/glass/items/projector.json b/glass/src/main/generated/assets/glass/items/projector.json new file mode 100644 index 0000000..7f8cc8b --- /dev/null +++ b/glass/src/main/generated/assets/glass/items/projector.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "glass:item/projector" + } +} diff --git a/glass/src/main/generated/assets/glass/items/redstone_infused_sand.json b/glass/src/main/generated/assets/glass/items/redstone_infused_sand.json new file mode 100644 index 0000000..9620da4 --- /dev/null +++ b/glass/src/main/generated/assets/glass/items/redstone_infused_sand.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "glass:item/redstone_infused_sand" + } +} diff --git a/glass/src/main/generated/assets/glass/items/terminal.json b/glass/src/main/generated/assets/glass/items/terminal.json new file mode 100644 index 0000000..4b322e7 --- /dev/null +++ b/glass/src/main/generated/assets/glass/items/terminal.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "glass:item/terminal" + } +} diff --git a/glass/src/main/generated/assets/glass/lang/en_us.json b/glass/src/main/generated/assets/glass/lang/en_us.json new file mode 100644 index 0000000..8dc1d54 --- /dev/null +++ b/glass/src/main/generated/assets/glass/lang/en_us.json @@ -0,0 +1,8 @@ +{ + "block.glass.powerable_glass": "Powerable Glass", + "block.glass.projector": "G.L.A.S.S Projector", + "block.glass.redstone_infused_sand": "Redstone Infused Sand", + "block.glass.terminal": "G.L.A.S.S Terminal", + "subtitles.glass.projection.panel_activate": "Projection panel activates", + "subtitles.glass.projection.panel_deactivate": "Projection panel deactivates" +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/block/powerable_glass.json b/glass/src/main/generated/assets/glass/models/block/powerable_glass.json new file mode 100644 index 0000000..ce6a483 --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/block/powerable_glass.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "glass:block/powerable_glass" + } +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/block/projector.json b/glass/src/main/generated/assets/glass/models/block/projector.json new file mode 100644 index 0000000..5dcd451 --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/block/projector.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/projector_assembly" +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/block/redstone_infused_sand.json b/glass/src/main/generated/assets/glass/models/block/redstone_infused_sand.json new file mode 100644 index 0000000..1ea5ad4 --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/block/redstone_infused_sand.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "glass:block/redstone_infused_sand" + } +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/block/terminal.json b/glass/src/main/generated/assets/glass/models/block/terminal.json new file mode 100644 index 0000000..4c404f2 --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/block/terminal.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/terminal_camera" +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/block/terminal_projecting.json b/glass/src/main/generated/assets/glass/models/block/terminal_projecting.json new file mode 100644 index 0000000..9c66a6d --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/block/terminal_projecting.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/terminal" +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/item/powerable_glass.json b/glass/src/main/generated/assets/glass/models/item/powerable_glass.json new file mode 100644 index 0000000..1269038 --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/item/powerable_glass.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/powerable_glass" +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/item/redstone_infused_sand.json b/glass/src/main/generated/assets/glass/models/item/redstone_infused_sand.json new file mode 100644 index 0000000..1a62bb9 --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/item/redstone_infused_sand.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/redstone_infused_sand" +} \ No newline at end of file diff --git a/glass/src/main/generated/assets/glass/models/item/terminal.json b/glass/src/main/generated/assets/glass/models/item/terminal.json new file mode 100644 index 0000000..9c66a6d --- /dev/null +++ b/glass/src/main/generated/assets/glass/models/item/terminal.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/terminal" +} \ No newline at end of file diff --git a/glass/src/main/generated/data/c/tags/block/glass_blocks.json b/glass/src/main/generated/data/c/tags/block/glass_blocks.json new file mode 100644 index 0000000..b5927f6 --- /dev/null +++ b/glass/src/main/generated/data/c/tags/block/glass_blocks.json @@ -0,0 +1,5 @@ +{ + "values": [ + "glass:powerable_glass" + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/advancement/recipes/building_blocks/powerable_glass.json b/glass/src/main/generated/data/glass/advancement/recipes/building_blocks/powerable_glass.json new file mode 100644 index 0000000..d777fcd --- /dev/null +++ b/glass/src/main/generated/data/glass/advancement/recipes/building_blocks/powerable_glass.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_ingredient": { + "conditions": { + "items": [ + { + "items": "glass:redstone_infused_sand" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "glass:powerable_glass" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_the_recipe", + "has_ingredient" + ] + ], + "rewards": { + "recipes": [ + "glass:powerable_glass" + ] + } +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/advancement/recipes/building_blocks/redstone_infused_sand.json b/glass/src/main/generated/data/glass/advancement/recipes/building_blocks/redstone_infused_sand.json new file mode 100644 index 0000000..2b1c545 --- /dev/null +++ b/glass/src/main/generated/data/glass/advancement/recipes/building_blocks/redstone_infused_sand.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_ingredient": { + "conditions": { + "items": [ + { + "items": "minecraft:redstone" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "glass:redstone_infused_sand" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_the_recipe", + "has_ingredient" + ] + ], + "rewards": { + "recipes": [ + "glass:redstone_infused_sand" + ] + } +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/advancement/recipes/redstone/projector.json b/glass/src/main/generated/data/glass/advancement/recipes/redstone/projector.json new file mode 100644 index 0000000..074a2a3 --- /dev/null +++ b/glass/src/main/generated/data/glass/advancement/recipes/redstone/projector.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_ingredient": { + "conditions": { + "items": [ + { + "items": "minecraft:quartz" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "glass:projector" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_the_recipe", + "has_ingredient" + ] + ], + "rewards": { + "recipes": [ + "glass:projector" + ] + } +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/advancement/recipes/redstone/terminal.json b/glass/src/main/generated/data/glass/advancement/recipes/redstone/terminal.json new file mode 100644 index 0000000..48723d3 --- /dev/null +++ b/glass/src/main/generated/data/glass/advancement/recipes/redstone/terminal.json @@ -0,0 +1,32 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_ingredient": { + "conditions": { + "items": [ + { + "items": "minecraft:observer" + } + ] + }, + "trigger": "minecraft:inventory_changed" + }, + "has_the_recipe": { + "conditions": { + "recipe": "glass:terminal" + }, + "trigger": "minecraft:recipe_unlocked" + } + }, + "requirements": [ + [ + "has_the_recipe", + "has_ingredient" + ] + ], + "rewards": { + "recipes": [ + "glass:terminal" + ] + } +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/loot_table/blocks/powerable_glass.json b/glass/src/main/generated/data/glass/loot_table/blocks/powerable_glass.json new file mode 100644 index 0000000..006a5e7 --- /dev/null +++ b/glass/src/main/generated/data/glass/loot_table/blocks/powerable_glass.json @@ -0,0 +1,32 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:match_tool", + "predicate": { + "predicates": { + "minecraft:enchantments": [ + { + "enchantments": "minecraft:silk_touch", + "levels": { + "min": 1 + } + } + ] + } + } + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "glass:powerable_glass" + } + ], + "rolls": 1.0 + } + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/loot_table/blocks/projector.json b/glass/src/main/generated/data/glass/loot_table/blocks/projector.json new file mode 100644 index 0000000..e308223 --- /dev/null +++ b/glass/src/main/generated/data/glass/loot_table/blocks/projector.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "glass:projector" + } + ], + "rolls": 1.0 + } + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/loot_table/blocks/redstone_infused_sand.json b/glass/src/main/generated/data/glass/loot_table/blocks/redstone_infused_sand.json new file mode 100644 index 0000000..7042ab6 --- /dev/null +++ b/glass/src/main/generated/data/glass/loot_table/blocks/redstone_infused_sand.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "glass:redstone_infused_sand" + } + ], + "rolls": 1.0 + } + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/loot_table/blocks/terminal.json b/glass/src/main/generated/data/glass/loot_table/blocks/terminal.json new file mode 100644 index 0000000..4454216 --- /dev/null +++ b/glass/src/main/generated/data/glass/loot_table/blocks/terminal.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "glass:terminal" + } + ], + "rolls": 1.0 + } + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/glass/recipe/powerable_glass.json b/glass/src/main/generated/data/glass/recipe/powerable_glass.json new file mode 100644 index 0000000..0dc6a3d --- /dev/null +++ b/glass/src/main/generated/data/glass/recipe/powerable_glass.json @@ -0,0 +1,10 @@ +{ + "type": "minecraft:smelting", + "category": "blocks", + "cookingtime": 200, + "experience": 0.1, + "ingredient": "glass:redstone_infused_sand", + "result": { + "id": "glass:powerable_glass" + } +} diff --git a/glass/src/main/generated/data/glass/recipe/projector.json b/glass/src/main/generated/data/glass/recipe/projector.json new file mode 100644 index 0000000..8fb9f3c --- /dev/null +++ b/glass/src/main/generated/data/glass/recipe/projector.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "redstone", + "key": { + "G": "glass:powerable_glass", + "O": "minecraft:obsidian", + "Q": "minecraft:quartz", + "R": "minecraft:redstone" + }, + "pattern": [ + "GQG", + "GRG", + "OOO" + ], + "result": { + "count": 1, + "id": "glass:projector" + } +} diff --git a/glass/src/main/generated/data/glass/recipe/redstone_infused_sand.json b/glass/src/main/generated/data/glass/recipe/redstone_infused_sand.json new file mode 100644 index 0000000..d977713 --- /dev/null +++ b/glass/src/main/generated/data/glass/recipe/redstone_infused_sand.json @@ -0,0 +1,12 @@ +{ + "type": "minecraft:crafting_shapeless", + "category": "building", + "ingredients": [ + "minecraft:redstone", + "minecraft:sand" + ], + "result": { + "count": 1, + "id": "glass:redstone_infused_sand" + } +} diff --git a/glass/src/main/generated/data/glass/recipe/terminal.json b/glass/src/main/generated/data/glass/recipe/terminal.json new file mode 100644 index 0000000..dbbdfad --- /dev/null +++ b/glass/src/main/generated/data/glass/recipe/terminal.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "redstone", + "key": { + "B": "minecraft:observer", + "E": "minecraft:ender_eye", + "O": "minecraft:obsidian" + }, + "pattern": [ + "OBO", + "OEO", + "OOO" + ], + "result": { + "count": 1, + "id": "glass:terminal" + } +} diff --git a/glass/src/main/generated/data/minecraft/tags/block/mineable/pickaxe.json b/glass/src/main/generated/data/minecraft/tags/block/mineable/pickaxe.json new file mode 100644 index 0000000..0251eae --- /dev/null +++ b/glass/src/main/generated/data/minecraft/tags/block/mineable/pickaxe.json @@ -0,0 +1,6 @@ +{ + "values": [ + "glass:projector", + "glass:terminal" + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/minecraft/tags/block/mineable/shovel.json b/glass/src/main/generated/data/minecraft/tags/block/mineable/shovel.json new file mode 100644 index 0000000..969a537 --- /dev/null +++ b/glass/src/main/generated/data/minecraft/tags/block/mineable/shovel.json @@ -0,0 +1,5 @@ +{ + "values": [ + "glass:redstone_infused_sand" + ] +} \ No newline at end of file diff --git a/glass/src/main/generated/data/minecraft/tags/block/needs_diamond_tool.json b/glass/src/main/generated/data/minecraft/tags/block/needs_diamond_tool.json new file mode 100644 index 0000000..63f0892 --- /dev/null +++ b/glass/src/main/generated/data/minecraft/tags/block/needs_diamond_tool.json @@ -0,0 +1,5 @@ +{ + "values": [ + "glass:terminal" + ] +} \ No newline at end of file diff --git a/glass/src/main/java/dev/imb11/Glass.java b/glass/src/main/java/dev/imb11/Glass.java new file mode 100644 index 0000000..0b7988c --- /dev/null +++ b/glass/src/main/java/dev/imb11/Glass.java @@ -0,0 +1,12 @@ +package dev.imb11; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class Glass { + public static final String MOD_ID = "glass"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + private Glass() { + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/GBlocks.java b/glass/src/main/java/dev/imb11/blocks/GBlocks.java new file mode 100644 index 0000000..5a0d702 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/GBlocks.java @@ -0,0 +1,44 @@ +package dev.imb11.blocks; + +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.client.gui.ProjectorBlockGUI; +import dev.imb11.client.gui.TerminalBlockGUI; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.tags.TagKey; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockBehaviour; + +public class GBlocks { + public static final TagKey GLASS_BLOCKS = TagKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("c", "glass_blocks")); + public static final TerminalBlock TERMINAL = new TerminalBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.OBSIDIAN).setId(ResourceKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("glass", "terminal"))).noOcclusion()); + public static final ProjectorBlock PROJECTOR = new ProjectorBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.BEACON).setId(ResourceKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("glass", "projector")))); + public static final RedstoneInfusedSandBlock REDSTONE_INFUSED_SAND = new RedstoneInfusedSandBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.SAND).setId(ResourceKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("glass", "redstone_infused_sand")))); + public static final PowerableGlassBlock POWERABLE_GLASS = new PowerableGlassBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.GLASS).setId(ResourceKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("glass", "powerable_glass")))); + + public static void registerBlocks() { + register("terminal", TERMINAL); + register("projector", PROJECTOR); + register("redstone_infused_sand", REDSTONE_INFUSED_SAND); + register("powerable_glass", POWERABLE_GLASS); + } + + public static void registerBlockEntities() { + Registry.register(BuiltInRegistries.BLOCK_ENTITY_TYPE, Identifier.fromNamespaceAndPath("glass", "terminal_entity"), TerminalBlockEntity.BLOCK_ENTITY_TYPE); + Registry.register(BuiltInRegistries.BLOCK_ENTITY_TYPE, Identifier.fromNamespaceAndPath("glass", "projector_entity"), ProjectorBlockEntity.BLOCK_ENTITY_TYPE); + } + + public static void registerMenus() { + Registry.register(BuiltInRegistries.MENU, Identifier.fromNamespaceAndPath("glass", "terminal_gui"), TerminalBlockGUI.SCREEN_HANDLER_TYPE); + Registry.register(BuiltInRegistries.MENU, Identifier.fromNamespaceAndPath("glass", "projector_gui"), ProjectorBlockGUI.SCREEN_HANDLER_TYPE); + } + + private static T register(String id, T block) { + return Registry.register(BuiltInRegistries.BLOCK, Identifier.fromNamespaceAndPath("glass", id), block); + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/GlassBlockShapes.java b/glass/src/main/java/dev/imb11/blocks/GlassBlockShapes.java new file mode 100644 index 0000000..5755a11 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/GlassBlockShapes.java @@ -0,0 +1,84 @@ +package dev.imb11.blocks; + +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +final class GlassBlockShapes { + static final VoxelShape[] PROJECTOR = projector(); + static final VoxelShape[] TERMINAL = terminal(); + + private GlassBlockShapes() { + } + + private static VoxelShape[] projector() { + VoxelShape shape = Shapes.or( + Block.box(0, 0, 0, 16, 1, 16), + Block.box(0, 0, 0, 1, 9, 16), + Block.box(15, 0, 0, 16, 9, 16), + Block.box(0, 0, 0, 16, 9, 1), + Block.box(0, 0, 15, 16, 9, 16), + Block.box(4, 0, 4, 12, 8, 12), + Block.box(5, 8, 5, 11, 16, 11)); + VoxelShape[] shapes = new VoxelShape[6]; + shapes[Direction.UP.ordinal()] = shape; + shapes[Direction.DOWN.ordinal()] = rotate(shape, 2, 0); + shapes[Direction.SOUTH.ordinal()] = rotate(shape, 1, 0); + shapes[Direction.WEST.ordinal()] = rotate(shape, 1, 1); + shapes[Direction.NORTH.ordinal()] = rotate(shape, 1, 2); + shapes[Direction.EAST.ordinal()] = rotate(shape, 1, 3); + return shapes; + } + + private static VoxelShape[] terminal() { + double eyeRadius = 3.0D * Math.sqrt(3.0D); + double eyeZ = 6.5D + Math.sqrt(2.0D); + VoxelShape shape = Shapes.or( + Block.box(0, 0, 0, 4, 4, 4), + Block.box(0, 0, 12, 4, 4, 16), + Block.box(12, 0, 12, 16, 4, 16), + Block.box(12, 4, 12, 15, 12, 15), + Block.box(1, 4, 12, 4, 12, 15), + Block.box(1, 4, 1, 4, 12, 4), + Block.box(12, 4, 1, 15, 12, 4), + Block.box(4, 1, 1, 12, 4, 4), + Block.box(12, 0, 0, 16, 4, 4), + Block.box(4, 12, 1, 12, 15, 4), + Block.box(4, 12, 12, 12, 15, 15), + Block.box(1, 12, 4, 4, 15, 12), + Block.box(12, 12, 4, 15, 15, 12), + Block.box(12, 1, 4, 15, 4, 12), + Block.box(1, 1, 4, 4, 4, 12), + Block.box(4, 1, 12, 12, 4, 15), + Block.box(0, 12, 12, 4, 16, 16), + Block.box(0, 12, 0, 4, 16, 4), + Block.box(12, 12, 12, 16, 16, 16), + Block.box(12, 12, 0, 16, 16, 4), + Block.box(8 - eyeRadius, 8 - eyeRadius, eyeZ - eyeRadius, 8 + eyeRadius, 8 + eyeRadius, eyeZ + eyeRadius)); + VoxelShape[] shapes = new VoxelShape[6]; + shapes[Direction.SOUTH.ordinal()] = shape; + shapes[Direction.NORTH.ordinal()] = rotate(shape, 0, 2); + shapes[Direction.WEST.ordinal()] = rotate(shape, 0, 1); + shapes[Direction.EAST.ordinal()] = rotate(shape, 0, 3); + shapes[Direction.UP.ordinal()] = rotate(shape, 1, 0); + shapes[Direction.DOWN.ordinal()] = rotate(shape, 3, 2); + return shapes; + } + + private static VoxelShape rotate(VoxelShape shape, int xTurns, int yTurns) { + for (int turn = 0; turn < xTurns; turn++) { + VoxelShape[] rotated = {Shapes.empty()}; + shape.forAllBoxes((minX, minY, minZ, maxX, maxY, maxZ) -> + rotated[0] = Shapes.or(rotated[0], Shapes.box(minX, minZ, 1 - maxY, maxX, maxZ, 1 - minY))); + shape = rotated[0]; + } + for (int turn = 0; turn < yTurns; turn++) { + VoxelShape[] rotated = {Shapes.empty()}; + shape.forAllBoxes((minX, minY, minZ, maxX, maxY, maxZ) -> + rotated[0] = Shapes.or(rotated[0], Shapes.box(1 - maxZ, minY, minX, 1 - minZ, maxY, maxX))); + shape = rotated[0]; + } + return shape.optimize(); + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/PowerableGlassBlock.java b/glass/src/main/java/dev/imb11/blocks/PowerableGlassBlock.java new file mode 100644 index 0000000..9686800 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/PowerableGlassBlock.java @@ -0,0 +1,18 @@ +package dev.imb11.blocks; + +import com.mojang.serialization.MapCodec; +import net.minecraft.world.level.block.TransparentBlock; + +public class PowerableGlassBlock extends TransparentBlock { + public static final MapCodec CODEC = simpleCodec(PowerableGlassBlock::new); + + public PowerableGlassBlock(Properties properties) { + super(properties); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + +} diff --git a/glass/src/main/java/dev/imb11/blocks/ProjectorBlock.java b/glass/src/main/java/dev/imb11/blocks/ProjectorBlock.java new file mode 100644 index 0000000..3e8b143 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/ProjectorBlock.java @@ -0,0 +1,109 @@ +package dev.imb11.blocks; + +import com.mojang.serialization.MapCodec; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.platform.ExtendedMenuProvider; +import dev.imb11.platform.Platform; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +public class ProjectorBlock extends BaseEntityBlock { + + public static final BooleanProperty POWERED = BlockStateProperties.POWERED; + public static final EnumProperty FACING = BlockStateProperties.FACING; + + public ProjectorBlock(Properties settings) { + super(settings); + registerDefaultState(defaultBlockState().setValue(POWERED, false)); + } + + @Override + protected MapCodec codec() { + return simpleCodec(ProjectorBlock::new); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING, POWERED); + } + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new ProjectorBlockEntity(pos, state); + } + + @Override + public void neighborChanged(BlockState state, Level world, BlockPos pos, Block sourceBlock, net.minecraft.world.level.redstone.Orientation orientation, boolean notify) { + if (!world.isClientSide()) { + boolean powered = world.hasNeighborSignal(pos); + if (state.getValue(POWERED) != powered) { + world.setBlock(pos, state.setValue(POWERED, powered), Block.UPDATE_CLIENTS); + } + } + + super.neighborChanged(state, world, pos, sourceBlock, orientation, notify); + } + + @Override + public InteractionResult useWithoutItem(BlockState state, Level world, BlockPos pos, Player player, BlockHitResult hit) { + + if (world.isClientSide()) { + return InteractionResult.PASS; + } + + ExtendedMenuProvider handlerFactory = (ExtendedMenuProvider) state.getMenuProvider(world, pos); + + Platform.openMenu(player, handlerFactory); + + return InteractionResult.SUCCESS; + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return GlassBlockShapes.PROJECTOR[state.getValue(FACING).ordinal()]; + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + @Override + public boolean skipRendering(BlockState state, BlockState adjacentState, Direction direction) { + return adjacentState.is(GBlocks.GLASS_BLOCKS) || super.skipRendering(state, adjacentState, direction); + } + + @Nullable + @Override + public BlockState getStateForPlacement(BlockPlaceContext ctx) { + return this.defaultBlockState() + .setValue(FACING, ctx.getNearestLookingDirection().getOpposite()) + .setValue(POWERED, ctx.getLevel().hasNeighborSignal(ctx.getClickedPos())); + } + + @Override + public BlockEntityTicker getTicker(Level world, BlockState state, BlockEntityType type) { + return BaseEntityBlock.createTickerHelper(type, ProjectorBlockEntity.BLOCK_ENTITY_TYPE, ProjectorBlockEntity::tick); + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/RedstoneInfusedSandBlock.java b/glass/src/main/java/dev/imb11/blocks/RedstoneInfusedSandBlock.java new file mode 100644 index 0000000..994d813 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/RedstoneInfusedSandBlock.java @@ -0,0 +1,34 @@ +package dev.imb11.blocks; + +import com.mojang.serialization.MapCodec; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.ColorRGBA; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.ColoredFallingBlock; +import net.minecraft.world.level.block.state.BlockState; + +public class RedstoneInfusedSandBlock extends ColoredFallingBlock { + public static final ColorRGBA DUST_COLOR = new ColorRGBA(0xCF2929); + public static final MapCodec CODEC = simpleCodec(RedstoneInfusedSandBlock::new); + public static final int SIGNAL_STRENGTH = 1; + + public RedstoneInfusedSandBlock(Properties properties) { + super(DUST_COLOR, properties); + } + + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + protected boolean isSignalSource(BlockState state) { + return true; + } + + @Override + protected int getSignal(BlockState state, BlockGetter level, BlockPos pos, Direction direction) { + return SIGNAL_STRENGTH; + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/TerminalBlock.java b/glass/src/main/java/dev/imb11/blocks/TerminalBlock.java new file mode 100644 index 0000000..7f88bb7 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/TerminalBlock.java @@ -0,0 +1,99 @@ +package dev.imb11.blocks; + +import com.mojang.serialization.MapCodec; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.platform.ExtendedMenuProvider; +import dev.imb11.platform.Platform; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.core.Direction; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; +import org.jetbrains.annotations.Nullable; + +public class TerminalBlock extends BaseEntityBlock { + public TerminalBlock(Properties settings) { + super(settings); + registerDefaultState(defaultBlockState().setValue(PROJECTING, false)); + } + + @Override + protected MapCodec codec() { + return simpleCodec(TerminalBlock::new); + } + + public static final EnumProperty FACING = BlockStateProperties.FACING; + public static final BooleanProperty PROJECTING = BooleanProperty.create("projecting"); + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return GlassBlockShapes.TERMINAL[state.getValue(FACING).ordinal()]; + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new TerminalBlockEntity(pos, state); + } + + @Override + protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos, boolean movedByPiston) { + if (!level.getBlockState(pos).is(this)) { + ChannelManagerPersistence.get(level).unlinkTerminal(level, pos); + } + super.affectNeighborsAfterRemoval(state, level, pos, movedByPiston); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level world, BlockPos pos, Player player, BlockHitResult hit) { + + if (world.isClientSide()) { + return InteractionResult.PASS; + } + + ExtendedMenuProvider handlerFactory = (ExtendedMenuProvider) state.getMenuProvider(world, pos); + + Platform.openMenu(player, handlerFactory); + + return InteractionResult.SUCCESS; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING, PROJECTING); + } + + @Nullable + @Override + public BlockState getStateForPlacement(BlockPlaceContext ctx) { + return this.defaultBlockState().setValue(FACING, ctx.getNearestLookingDirection()); + } + + @Nullable + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType type) { + return BaseEntityBlock.createTickerHelper(type, TerminalBlockEntity.BLOCK_ENTITY_TYPE, TerminalBlockEntity::tick); + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/TestProjectorBlock.java b/glass/src/main/java/dev/imb11/blocks/TestProjectorBlock.java new file mode 100644 index 0000000..e69de29 diff --git a/glass/src/main/java/dev/imb11/blocks/TestProjectorBlockEntity.java b/glass/src/main/java/dev/imb11/blocks/TestProjectorBlockEntity.java new file mode 100644 index 0000000..e69de29 diff --git a/glass/src/main/java/dev/imb11/blocks/entity/ProjectorBlockEntity.java b/glass/src/main/java/dev/imb11/blocks/entity/ProjectorBlockEntity.java new file mode 100644 index 0000000..ec54434 --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/entity/ProjectorBlockEntity.java @@ -0,0 +1,289 @@ +package dev.imb11.blocks.entity; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.debug.ProjectorPowerTrace; +import dev.imb11.blocks.ProjectorBlock; +import dev.imb11.client.gui.ProjectorBlockGUI; +import dev.imb11.projection.ProjectionSurface; +import dev.imb11.sounds.GSounds; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.platform.PlatformBlockEntity; +import dev.imb11.platform.ExtendedMenuProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.SectionPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public class ProjectorBlockEntity extends PlatformBlockEntity implements ExtendedMenuProvider { + public static BlockEntityType BLOCK_ENTITY_TYPE = new BlockEntityType<>(ProjectorBlockEntity::new, java.util.Set.of(GBlocks.PROJECTOR)); + public boolean active = false; + private String channel = ""; + + private ProjectionSurface projectionSurface; + private int revealDistance = -1; + private int clientRevealDistance = -1; + private boolean clientProjectionReady; + private long lastPreparationTick = Long.MIN_VALUE; + private long lastRenderedFrame = Long.MIN_VALUE; + private long loadingAnimationStart = -1L; + + public ProjectorBlockEntity(BlockPos pos, BlockState state) { + super(BLOCK_ENTITY_TYPE, pos, state); + } + + public static void tick(Level world, BlockPos pos, BlockState state, ProjectorBlockEntity be) { + boolean wasActive = be.active; + be.active = world.isClientSide() ? state.getValue(ProjectorBlock.POWERED) : world.hasNeighborSignal(pos); + if (!world.isClientSide() && state.getValue(ProjectorBlock.POWERED) != be.active) { + world.setBlock(pos, state.setValue(ProjectorBlock.POWERED, be.active), Block.UPDATE_CLIENTS); + } + if (wasActive != be.active) { + ProjectorPowerTrace.changed(world, pos, be.channel, "neighbor-signal", wasActive, be.active, be.getRevealDistance()); + be.setChanged(); + } + if (!wasActive && be.active) { + be.revealDistance = -1; + be.clientRevealDistance = -1; + } + be.tickProjection(world); + if (!world.isClientSide() && be.isProjectionVisible()) { + ChannelManagerPersistence.get(world).recordProjection(be.channel); + } + } + + @Override + public void saveAdditional(ValueOutput tag) { + tag.putString("channel", channel); + tag.putBoolean("active", active); + tag.putInt("targetDistance", revealDistance); + + super.saveAdditional(tag); + } + + @Override + public void loadAdditional(ValueInput tag) { + boolean wasActive = active; + String previousChannel = channel; + channel = tag.getStringOr("channel", ""); + active = tag.getBooleanOr("active", false); + revealDistance = tag.getIntOr("targetDistance", -1); + if (!wasActive && active || !previousChannel.equals(channel)) { + clientRevealDistance = -1; + clientProjectionReady = false; + } + if (level != null && wasActive != active) { + ProjectorPowerTrace.changed(level, worldPosition, channel, "block-entity-sync", wasActive, active, getRevealDistance()); + } + + super.loadAdditional(tag); + } + + public String getChannel() { + return channel; + } + + public void setChannelFromServer(String channel) { + String value = Objects.requireNonNull(channel); + if (this.channel.equals(value)) { + return; + } + this.channel = value; + setChanged(); + if (level != null && !level.isClientSide()) { + BlockState state = getBlockState(); + level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS); + } + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider registryLookup) { + return saveWithoutMetadata(registryLookup); + } + + @Override + public Component getDisplayName() { + return Component.literal("G.L.A.S.S Projector"); + } + + @Override + public AbstractContainerMenu createMenu(int syncId, Inventory inventory, Player player) { + return new ProjectorBlockGUI(syncId, inventory, worldPosition); + } + + @Override + public StreamCodec getScreenOpeningCodec() { + return BlockPos.STREAM_CODEC; + } + + @Override + public BlockPos getScreenOpeningData(ServerPlayer player) { + return worldPosition; + } + + private void tickProjection(Level world) { + if (!active && getRevealDistance() < 0) { + return; + } + Direction facing = getBlockState().getValue(ProjectorBlock.FACING); + ensureProjectionSurface(world, facing); + if (world.isClientSide()) { + if (active) { + if (clientProjectionReady && clientRevealDistance < projectionSurface.completedRevealDistance()) { + clientRevealDistance++; + playPanelTransitionSounds(world, GSounds.PROJECTION_PANEL_ACTIVATE, clientRevealDistance); + } + } else if (clientRevealDistance >= 0) { + playPanelTransitionSounds(world, GSounds.PROJECTION_PANEL_DEACTIVATE, clientRevealDistance); + clientRevealDistance--; + } + return; + } + int oldRevealDistance = revealDistance; + if (active) { + revealDistance = Math.min(projectionSurface.completedRevealDistance(), revealDistance + 1); + } else { + revealDistance = Math.max(-1, revealDistance - 1); + } + if (revealDistance != oldRevealDistance) { + setChanged(); + } + } + + private void playPanelTransitionSounds(Level world, SoundEvent sound, int distance) { + for (ProjectionSurface.Face face : projectionSurface.faces()) { + if (face.revealDistance() != distance) { + continue; + } + BlockPos position = face.position(); + Direction normal = face.normal(); + world.playLocalSound( + position.getX() + 0.5D + normal.getStepX() * 0.5D, + position.getY() + 0.5D + normal.getStepY() * 0.5D, + position.getZ() + 0.5D + normal.getStepZ() * 0.5D, + sound, + SoundSource.BLOCKS, + 0.6F, + 0.8F + world.getRandom().nextFloat() * 0.4F, + false + ); + } + } + + private void ensureProjectionSurface(Level world, Direction facing) { + boolean facingChanged = projectionSurface != null && projectionSurface.facing() != facing; + ProjectionSurface.BlockQuery query = position -> sampleBlock(world, position); + if (projectionSurface != null && projectionSurface.isTopologyValid(facing, query)) { + return; + } + + ProjectionSurface rebuilt = ProjectionSurface.rebuild( + worldPosition, + facing, + query + ); + boolean topologyChanged = !rebuilt.equals(projectionSurface); + projectionSurface = rebuilt; + if (!topologyChanged) { + return; + } + if (facingChanged) { + revealDistance = -1; + clientRevealDistance = -1; + } else { + revealDistance = Math.min(revealDistance, rebuilt.completedRevealDistance()); + clientRevealDistance = Math.min(clientRevealDistance, rebuilt.completedRevealDistance()); + } + setChanged(); + } + + private static ProjectionSurface.BlockSample sampleBlock(Level world, BlockPos position) { + int chunkX = SectionPos.blockToSectionCoord(position.getX()); + int chunkZ = SectionPos.blockToSectionCoord(position.getZ()); + if (!world.getChunkSource().hasChunk(chunkX, chunkZ)) { + return ProjectionSurface.BlockSample.UNLOADED; + } + return world.getBlockState(position).is(GBlocks.POWERABLE_GLASS) + ? ProjectionSurface.BlockSample.GLASS + : ProjectionSurface.BlockSample.OTHER; + } + + @Nullable + public ProjectionSurface getProjectionSurface() { + return projectionSurface; + } + + public void prepareProjectionSurface() { + if (level != null) { + long tick = level.getGameTime(); + if (projectionSurface != null && projectionSurface.facing() == getBlockState().getValue(ProjectorBlock.FACING) + && lastPreparationTick != Long.MIN_VALUE && tick - lastPreparationTick < 10L) { + return; + } + lastPreparationTick = tick; + ensureProjectionSurface(level, getBlockState().getValue(ProjectorBlock.FACING)); + } + } + + public boolean claimRenderFrame(long frame) { + if (lastRenderedFrame == frame) { + return false; + } + lastRenderedFrame = frame; + return true; + } + + public long loadingAnimationStart(long now) { + if (loadingAnimationStart < 0L) { + loadingAnimationStart = now; + } + return loadingAnimationStart; + } + + public void stopLoadingAnimation() { + loadingAnimationStart = -1L; + } + + public void setClientProjectionReady(boolean ready) { + clientProjectionReady = ready; + } + + public int getRevealDistance() { + return level != null && level.isClientSide() ? clientRevealDistance : revealDistance; + } + + public boolean isProjectionVisible() { + return projectionSurface != null && (active || getRevealDistance() >= 0); + } + + public boolean isActive() { + return active; + } +} diff --git a/glass/src/main/java/dev/imb11/blocks/entity/TerminalBlockEntity.java b/glass/src/main/java/dev/imb11/blocks/entity/TerminalBlockEntity.java new file mode 100644 index 0000000..894954f --- /dev/null +++ b/glass/src/main/java/dev/imb11/blocks/entity/TerminalBlockEntity.java @@ -0,0 +1,149 @@ +package dev.imb11.blocks.entity; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.TerminalBlock; +import dev.imb11.client.gui.TerminalBlockGUI; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.platform.PlatformBlockEntity; +import dev.imb11.platform.ExtendedMenuProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import net.minecraft.util.RandomSource; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +public class TerminalBlockEntity extends PlatformBlockEntity implements ExtendedMenuProvider { + public static BlockEntityType BLOCK_ENTITY_TYPE = new BlockEntityType<>(TerminalBlockEntity::new, java.util.Set.of(GBlocks.TERMINAL)); + + private String channel = ""; + private Direction reconciledFacing; + private final Quaternionf eyeRotation = new Quaternionf(); + private final Quaternionf previousEyeRotation = new Quaternionf(); + private final Vector3f eyeVelocity = new Vector3f(); + private final Vector3f targetEyeVelocity = new Vector3f(); + private final RandomSource eyeRandom; + private int eyeDirectionTicks; + + public TerminalBlockEntity(BlockPos pos, BlockState state) { + super(BLOCK_ENTITY_TYPE, pos, state); + eyeRandom = RandomSource.create(pos.asLong()); + } + + public static void tick(Level level, BlockPos pos, BlockState state, TerminalBlockEntity terminal) { + if (level.isClientSide()) { + terminal.previousEyeRotation.set(terminal.eyeRotation); + if (state.getValue(TerminalBlock.PROJECTING)) { + if (terminal.eyeDirectionTicks-- <= 0) { + double azimuth = terminal.eyeRandom.nextDouble() * Math.PI * 2.0D; + float vertical = terminal.eyeRandom.nextFloat() * 2.0F - 1.0F; + float horizontal = (float) Math.sqrt(1.0F - vertical * vertical); + terminal.targetEyeVelocity.set(horizontal * (float) Math.cos(azimuth), vertical, + horizontal * (float) Math.sin(azimuth)).mul(0.09F); + terminal.eyeDirectionTicks = 40 + terminal.eyeRandom.nextInt(60); + } + terminal.eyeVelocity.lerp(terminal.targetEyeVelocity, 0.04F); + terminal.eyeRotation.rotateXYZ(terminal.eyeVelocity.x(), terminal.eyeVelocity.y(), terminal.eyeVelocity.z()).normalize(); + } + return; + } + if (!(level instanceof ServerLevel serverLevel) || !state.hasProperty(TerminalBlock.FACING)) { + return; + } + Direction facing = state.getValue(TerminalBlock.FACING); + ChannelManagerPersistence channels = ChannelManagerPersistence.get(serverLevel); + if (terminal.reconciledFacing != facing) { + terminal.reconciledFacing = facing; + channels.reconcileTerminal(terminal); + } + boolean projecting = channels.isProjecting(serverLevel, pos, terminal.channel); + if (state.getValue(TerminalBlock.PROJECTING) != projecting) { + level.setBlock(pos, state.setValue(TerminalBlock.PROJECTING, projecting), Block.UPDATE_CLIENTS); + } + } + + public Quaternionf getEyeRotation(float tickDelta) { + return new Quaternionf(previousEyeRotation).slerp(eyeRotation, tickDelta); + } + + @Override + public void saveAdditional(ValueOutput tag) { + super.saveAdditional(tag); + tag.putString("channel", channel); + } + + @Override + public void loadAdditional(ValueInput tag) { + super.loadAdditional(tag); + channel = tag.getStringOr("channel", ""); + } + + public String getChannel() { + return channel; + } + + public void setChannelFromServer(String channel) { + String value = Objects.requireNonNull(channel); + if (this.channel.equals(value)) { + return; + } + this.channel = value; + setChanged(); + if (level != null && !level.isClientSide()) { + BlockState state = getBlockState(); + level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS); + } + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider lookup) { + return saveWithoutMetadata(lookup); + } + + @Override + public Component getDisplayName() { + return Component.literal("G.L.A.S.S Terminal"); + } + + @Override + public AbstractContainerMenu createMenu(int syncId, Inventory inventory, Player player) { + return new TerminalBlockGUI(syncId, inventory, worldPosition); + } + + @Override + public StreamCodec getScreenOpeningCodec() { + return BlockPos.STREAM_CODEC; + } + + @Override + public BlockPos getScreenOpeningData(ServerPlayer player) { + return worldPosition; + } +} diff --git a/glass/src/main/java/dev/imb11/client/ClientProjectionSourceRegistry.java b/glass/src/main/java/dev/imb11/client/ClientProjectionSourceRegistry.java new file mode 100644 index 0000000..56110f9 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/ClientProjectionSourceRegistry.java @@ -0,0 +1,80 @@ +package dev.imb11.client; + +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import dev.imb11.sync.Channel; +import dev.imb11.sync.ProjectionSource; +import dev.imb11.sync.packets.S2CChannelSnapshotPacket; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ClientProjectionSourceRegistry { + private static final long EMPTY_REVISION = -1L; + private static volatile Snapshot snapshot = Snapshot.empty(); + + private ClientProjectionSourceRegistry() { + } + + public static long registryRevision() { + return snapshot.registryRevision(); + } + + public static Map channels() { + return snapshot.channels(); + } + + public static ProjectionSource resolve(String channel) { + return channel == null ? null : snapshot.sourcesByChannel().get(channel); + } + + public static synchronized boolean applySnapshot(S2CChannelSnapshotPacket packet) { + return applySnapshot(packet.registryRevision(), packet.channels()); + } + + public static synchronized boolean applySnapshot(long registryRevision, List channels) { + Snapshot current = snapshot; + if (registryRevision < current.registryRevision()) { + return false; + } + + Map replacements = new LinkedHashMap<>(); + Map sources = new LinkedHashMap<>(); + for (Channel channel : channels) { + if (channel == null || channel.name() == null) { + continue; + } + replacements.put(channel.name(), channel); + ProjectionSource source = channel.source(); + if (source != null && channel.name().equals(source.channel())) { + sources.put(channel.name(), source); + } + } + + Snapshot replacement = new Snapshot(registryRevision, replacements, sources); + snapshot = replacement; + ProjectionRenderManager.onRegistryReplaced(replacement.sourcesByChannel()); + return true; + } + + public static synchronized void clear() { + Snapshot replacement = Snapshot.empty(); + snapshot = replacement; + ProjectionRenderManager.onRegistryReplaced(replacement.sourcesByChannel()); + } + + private record Snapshot( + long registryRevision, + Map channels, + Map sourcesByChannel + ) { + private Snapshot { + channels = Map.copyOf(channels); + sourcesByChannel = Map.copyOf(sourcesByChannel); + } + + private static Snapshot empty() { + return new Snapshot(EMPTY_REVISION, Map.of(), Map.of()); + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/GlassClient.java b/glass/src/main/java/dev/imb11/client/GlassClient.java new file mode 100644 index 0000000..5dedd89 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/GlassClient.java @@ -0,0 +1,99 @@ +package dev.imb11.client; + +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.client.renderer.block.ProjectorBlockEntityRenderer; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import dev.imb11.client.renderer.projection.ProjectionSurfaceRenderer; +import dev.imb11.client.remote.RemoteSceneClientManager; +import dev.imb11.platform.ClientNetworking; +import dev.imb11.sync.packets.S2CChannelSnapshotPacket; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.LevelChunk; + +import java.io.IOException; + +public final class GlassClient { + private static volatile ClientLevel activeLevel; + + private GlassClient() { + } + + public static void initialize() { + ClientNetworking.registerReceiver(S2CChannelSnapshotPacket.PACKET_ID, ClientProjectionSourceRegistry::applySnapshot); + RemoteSceneClientManager.registerReceivers(); + } + + public static void onResourceReload() { + ProjectionRenderManager.reset(); + RemoteSceneClientManager.onResourceReload(); + ProjectorBlockEntityRenderer.reset(); + ProjectionSurfaceRenderer.reset(); + } + + public static void invalidateRenderState() { + ProjectionSurfaceRenderer.reset(); + ProjectorBlockEntityRenderer.reset(); + } + + public static void onChunkUnload(ClientLevel level, LevelChunk chunk) { + ProjectionRenderManager.onClientChunkUnloaded(level, chunk.getPos()); + } + + public static void onBlockEntityLoad(BlockEntity blockEntity, ClientLevel level) { + if (level == Minecraft.getInstance().level && blockEntity instanceof ProjectorBlockEntity projector) { + ProjectorBlockEntityRenderer.registerLoaded(level, projector); + } + } + + public static void onBlockEntityUnload(BlockEntity blockEntity, ClientLevel level) { + if (level == Minecraft.getInstance().level && blockEntity instanceof ProjectorBlockEntity projector) { + ProjectorBlockEntityRenderer.unregisterLoaded(level, projector); + ProjectionSurfaceRenderer.release(level, projector.getBlockPos()); + ProjectionRenderManager.releaseProjector(level, projector.getBlockPos()); + } + } + + public static void tick(Minecraft minecraft) { + trackLevel(minecraft); + RemoteSceneClientManager.tick(minecraft); + ProjectionRenderManager.drainRetiredBufferPools(); + } + + private static void trackLevel(Minecraft minecraft) { + ClientLevel currentLevel = minecraft.level; + if (activeLevel == currentLevel) { + return; + } + + ClientLevel previousLevel = activeLevel; + activeLevel = currentLevel; + if (previousLevel != null) { + ProjectionSurfaceRenderer.releaseLevel(previousLevel); + ProjectorBlockEntityRenderer.releaseLevel(previousLevel); + } + if (currentLevel == null) { + ProjectionRenderManager.reset(); + } else { + ProjectorBlockEntityRenderer.onMainRendererRebuilt(minecraft.levelRenderer); + ProjectionRenderManager.onClientLevelChanged(currentLevel); + } + RemoteSceneClientManager.onMainLevelChanged(currentLevel); + } + + public static void clearConnection() { + Minecraft minecraft = Minecraft.getInstance(); + if (!minecraft.isSameThread()) { + minecraft.execute(GlassClient::clearConnection); + return; + } + activeLevel = null; + ClientProjectionSourceRegistry.clear(); + ProjectionRenderManager.reset(); + RemoteSceneClientManager.clearConnection(); + ProjectionSurfaceRenderer.reset(); + ProjectorBlockEntityRenderer.reset(); + ProjectorBlockEntityRenderer.clearLoaded(); + } +} diff --git a/glass/src/main/java/dev/imb11/client/compat/SodiumCompatibility.java b/glass/src/main/java/dev/imb11/client/compat/SodiumCompatibility.java new file mode 100644 index 0000000..83190a9 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/compat/SodiumCompatibility.java @@ -0,0 +1,55 @@ +package dev.imb11.client.compat; + +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import net.caffeinemc.mods.sodium.client.render.chunk.map.ChunkStatus; +import net.caffeinemc.mods.sodium.client.render.chunk.map.ChunkTrackerHolder; +import net.caffeinemc.mods.sodium.client.render.texture.SpriteContentsExtension; +import dev.imb11.platform.Platform; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.world.level.ChunkPos; + +import java.util.List; + +public final class SodiumCompatibility { + private static final boolean LOADED = Platform.isModLoaded("sodium"); + + private SodiumCompatibility() { + } + + public static void onChunkReadyToRender(ClientLevel level, ChunkPos pos) { + if (LOADED) { + SodiumAccess.loaded(level, pos); + } + } + + public static void onChunkUnloaded(ClientLevel level, ChunkPos pos) { + if (LOADED) { + SodiumAccess.unloaded(level, pos); + } + } + + public static void activateProjectionTextures(List sprites) { + if (LOADED && ProjectionRenderManager.hasVisibleProjection()) { + SodiumAccess.activateTextures(sprites); + } + } + + private static final class SodiumAccess { + private static void loaded(ClientLevel level, ChunkPos pos) { + ChunkTrackerHolder.get(level).onChunkStatusAdded(pos.x(), pos.z(), ChunkStatus.FLAG_HAS_BLOCK_DATA); + } + + private static void unloaded(ClientLevel level, ChunkPos pos) { + ChunkTrackerHolder.get(level).onChunkStatusRemoved(pos.x(), pos.z(), ChunkStatus.FLAG_ALL); + } + + private static void activateTextures(List sprites) { + for (TextureAtlasSprite sprite : sprites) { + if (sprite.contents() instanceof SpriteContentsExtension extension && extension.sodium$hasAnimation()) { + extension.sodium$setActive(true); + } + } + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/ChannelMenu.java b/glass/src/main/java/dev/imb11/client/gui/ChannelMenu.java new file mode 100644 index 0000000..43a2183 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/ChannelMenu.java @@ -0,0 +1,37 @@ +package dev.imb11.client.gui; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; + +public abstract class ChannelMenu extends AbstractContainerMenu { + public final BlockPos pos; + private final ContainerLevelAccess access; + private final Block block; + + protected ChannelMenu(MenuType type, int id, Inventory inventory, BlockPos pos, Block block) { + super(type, id); + this.pos = pos.immutable(); + this.block = block; + this.access = ContainerLevelAccess.create(inventory.player.level(), pos); + } + + public boolean isFor(BlockPos pos) { + return this.pos.equals(pos); + } + + @Override + public boolean stillValid(Player player) { + return stillValid(access, player, block); + } + + @Override + public ItemStack quickMoveStack(Player player, int slot) { + return ItemStack.EMPTY; + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/ChannelScreen.java b/glass/src/main/java/dev/imb11/client/gui/ChannelScreen.java new file mode 100644 index 0000000..6f26133 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/ChannelScreen.java @@ -0,0 +1,473 @@ +package dev.imb11.client.gui; + +import dev.imb11.client.ClientProjectionSourceRegistry; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import dev.imb11.platform.ClientNetworking; +import dev.imb11.sync.Channel; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.packets.C2SCreateChannelPacket; +import dev.imb11.sync.packets.C2SDeleteChannelPacket; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.ObjectSelectionList; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public abstract class ChannelScreen extends AbstractContainerScreen { + private final boolean manage; + private List channels = List.of(); + private long revision = -1; + private String selectedName; + private String currentName; + private String pendingCreation; + private int pendingTicks; + private ProjectionRenderManager.ProjectionFeed preview; + private boolean initialized; + private boolean confirmingDelete; + private EditBox search; + private ChannelList list; + private Button choose; + private Button delete; + private Button cancel; + private Button unlink; + private int listWidth; + private int detailX; + private int detailWidth; + private int matches; + private int panelWidth; + private int panelHeight; + + protected ChannelScreen(M menu, Inventory inventory, Component title, boolean manage) { + super(menu, inventory, title); + this.manage = manage; + } + + protected abstract boolean isCurrent(Channel channel); + + protected abstract boolean canChoose(Channel channel); + + protected abstract void choose(Channel channel); + + protected abstract Component chooseLabel(); + + protected abstract Component status(Channel channel); + + protected abstract String unavailableLabel(); + + protected void unlink() { + } + + @Override + protected void init() { + String query = search == null ? "" : search.getValue(); + double scroll = list == null ? 0 : list.scrollAmount(); + panelWidth = Math.min(620, width - 16); + panelHeight = Math.min(340, height - 16); + super.init(); + leftPos = (width - panelWidth) / 2; + topPos = (height - panelHeight) / 2; + listWidth = Math.max(112, panelWidth * 35 / 100); + detailX = leftPos + listWidth + 12; + detailWidth = panelWidth - listWidth - 24; + refreshChannels(); + if (!initialized) { + Channel current = current(); + selectedName = current == null ? null : current.name(); + } + search = addRenderableWidget(new EditBox(font, leftPos + 10, topPos + 36, listWidth - 18, 20, + Component.literal(manage ? "Search or create channel" : "Search channels"))); + search.setMaxLength(ChannelManagerPersistence.MAX_CHANNEL_NAME_LENGTH); + search.setHint(Component.literal(manage ? "Search or create…" : "Search…")); + search.setValue(query); + list = addRenderableWidget(new ChannelList(leftPos + 6, topPos + 76, listWidth - 8, panelHeight - 84)); + search.setResponder(value -> { + confirmingDelete = false; + rebuildList(); + list.setScrollAmount(0); + updateActions(); + }); + int actionY = topPos + panelHeight - 30; + int deleteWidth = manage ? Math.min(64, detailWidth / 3) : 0; + int chooseWidth = manage ? detailWidth - deleteWidth - 6 : detailWidth; + choose = addRenderableWidget(Button.builder(chooseLabel(), button -> { + Channel selected = selected(); + if (selected != null && canChoose(selected)) { + choose(selected); + } + }).bounds(detailX, actionY, chooseWidth, 20).build()); + if (manage) { + unlink = addRenderableWidget(Button.builder(Component.literal("Unlink"), button -> unlink()) + .bounds(leftPos + panelWidth - 66, topPos + 6, 56, 20).build()); + cancel = addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> { + confirmingDelete = false; + updateActions(); + }).bounds(detailX, actionY, chooseWidth, 20).build()); + delete = addRenderableWidget(Button.builder(Component.literal("Delete"), button -> { + Channel selected = selected(); + if (selected == null || ChannelManagerPersistence.DEFAULT_CHANNEL.equals(selected.name())) { + return; + } + if (confirmingDelete) { + ClientNetworking.send(new C2SDeleteChannelPacket(menu.pos, selected.name())); + confirmingDelete = false; + } else { + confirmingDelete = true; + } + updateActions(); + }).bounds(detailX + detailWidth - deleteWidth, actionY, deleteWidth, 20).build()); + } + rebuildList(); + list.setScrollAmount(scroll); + if (!initialized && list.getSelected() != null) { + list.revealSelection(); + } + initialized = true; + updateActions(); + } + + private void refreshChannels() { + revision = ClientProjectionSourceRegistry.registryRevision(); + channels = ClientProjectionSourceRegistry.channels().values().stream() + .sorted(Comparator.comparing(Channel::name, String.CASE_INSENSITIVE_ORDER).thenComparing(Channel::name)).toList(); + Channel current = current(); + currentName = current == null ? null : current.name(); + if (pendingCreation != null && channels.stream().anyMatch(channel -> channel.name().equals(pendingCreation))) { + selectedName = pendingCreation; + pendingCreation = null; + } + if (selectedName != null && selected() == null) { + selectedName = null; + confirmingDelete = false; + } + } + + private Channel current() { + return channels.stream().filter(this::isCurrent).findFirst().orElse(null); + } + + private Channel selected() { + return channels.stream().filter(channel -> channel.name().equals(selectedName)).findFirst().orElse(null); + } + + private String creationName() { + if (!manage) { + return null; + } + String name = ChannelManagerPersistence.canonicalChannelName(search.getValue()); + return name != null && channels.stream().noneMatch(channel -> channel.name().equals(name)) ? name : null; + } + + private void createChannel() { + String name = creationName(); + if (name != null && pendingCreation == null && channels.size() < ChannelManagerPersistence.MAX_CHANNELS) { + pendingCreation = name; + pendingTicks = 100; + ClientNetworking.send(new C2SCreateChannelPacket(menu.pos, name)); + } + } + + private void rebuildList() { + double scroll = list.scrollAmount(); + list.clearRows(); + String query = search.getValue().strip().toLowerCase(Locale.ROOT); + matches = 0; + for (Channel channel : channels) { + if (channel.name().toLowerCase(Locale.ROOT).contains(query)) { + ChannelRow row = new ChannelRow(channel, null); + list.addRow(row); + matches++; + if (channel.name().equals(selectedName)) { + list.setSelected(row); + } + } + } + String name = creationName(); + if (name != null) { + list.addRow(new ChannelRow(null, name)); + } + list.setScrollAmount(scroll); + } + + private void updateActions() { + Channel selected = selected(); + choose.visible = selected != null && !isCurrent(selected) && !confirmingDelete; + choose.active = selected != null && canChoose(selected); + if (manage) { + unlink.visible = current() != null; + delete.visible = selected != null; + delete.active = selected != null && !ChannelManagerPersistence.DEFAULT_CHANNEL.equals(selected.name()); + delete.setTooltip(delete.active ? null : Tooltip.create(Component.literal("The Default channel cannot be deleted."))); + cancel.visible = selected != null && confirmingDelete; + } + } + + @Override + protected void containerTick() { + super.containerTick(); + if (pendingCreation != null && --pendingTicks <= 0) { + pendingCreation = null; + } + Channel current = current(); + boolean currentChanged = !Objects.equals(currentName, current == null ? null : current.name()); + if (ClientProjectionSourceRegistry.registryRevision() > revision) { + boolean created = pendingCreation != null; + refreshChannels(); + confirmingDelete = false; + rebuildList(); + if (created && pendingCreation == null && list.getSelected() != null) { + list.revealSelection(); + } + updateActions(); + } else if (currentChanged) { + currentName = current == null ? null : current.name(); + updateActions(); + } + } + + public void preparePreview() { + Channel selected = selected(); + if (selected == null || selected.source() == null) { + releasePreview(); + return; + } + preview = ProjectionRenderManager.requestPreview(selected.source(), menu.pos, + (float) detailWidth / Math.max(1, previewBottom() - topPos - 38)); + } + + private void releasePreview() { + if (preview != null && minecraft.level != null) { + ProjectionRenderManager.releasePreview(minecraft.level, menu.pos); + } + preview = null; + } + + @Override + public void removed() { + releasePreview(); + super.removed(); + } + + private Component detailStatus(Channel selected) { + if (confirmingDelete) { + return Component.literal("Delete " + selected.name() + "? This cannot be undone." + + (selected.source() == null ? "" : " Unlinks terminal at " + selected.source().pos().toShortString() + + " (" + selected.source().dimension().identifier() + ").")); + } + return status(selected); + } + + private int previewBottom() { + Channel selected = selected(); + int lines = selected == null ? 2 : font.split(detailStatus(selected), detailWidth).size(); + return Math.max(topPos + 66, topPos + panelHeight - Math.max(88, 60 + lines * font.lineHeight)); + } + + @Override + public void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + InventoryPanels.panel(graphics, leftPos, topPos, panelWidth, panelHeight); + Channel selected = selected(); + int previewTop = topPos + 38; + int previewBottom = previewBottom(); + InventoryPanels.inset(graphics, detailX, previewTop, detailWidth, previewBottom - previewTop); + if (selected != null) { + boolean ready = preview != null && selected.source() != null && selected.source().equals(preview.source()) + && ProjectionRenderManager.isReady(preview); + if (ready) { + // Terrain writes texture * tint alpha into the feed, so tinted blocks (leaves, grass) aren't opaque there. + // Blit without blending so the panel doesn't show through; v runs 1 -> 0 to flip the render target. + graphics.blit(RenderPipelines.GUI_OPAQUE_TEXTURED_BACKGROUND, preview.textureLocation(), detailX + 1, previewTop + 1, + 0.0F, 1.0F, detailWidth - 2, previewBottom - previewTop - 2, 1, -1, 1, 1); + } else { + String message = selected.source() == null ? "No linked camera" + : preview != null && preview.isFailed() ? "Camera unavailable" : "Loading camera…"; + graphics.centeredText(font, fit(message, detailWidth - 12), + detailX + detailWidth / 2, (previewTop + previewBottom) / 2 - 4, 0xFFFFFFFF); + } + int textY = previewBottom + 8; + graphics.text(font, fit(selected.name(), detailWidth), detailX, textY, 0xFF404040, false); + graphics.textWithWordWrap(font, detailStatus(selected), detailX, textY + 14, detailWidth, confirmingDelete ? 0xFFA02020 : 0xFF404040, false); + } else { + graphics.centeredText(font, "Select a channel", detailX + detailWidth / 2, (previewTop + previewBottom) / 2 - 4, 0xFFFFFFFF); + graphics.textWithWordWrap(font, Component.literal(manage ? "Select a channel to preview it, or search to create one." + : "Select a channel to preview it."), detailX, previewBottom + 10, detailWidth, 0xFF404040, false); + } + super.extractContents(graphics, mouseX, mouseY, partialTick); + } + + private String fit(String text, int availableWidth) { + return font.width(text) <= availableWidth ? text : font.plainSubstrByWidth(text, Math.max(0, availableWidth - font.width("…"))) + "…"; + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + graphics.text(font, title, 10, 12, 0xFF404040, false); + Channel current = current(); + String status = current == null ? "Not linked" : "Linked to " + current.name(); + int right = panelWidth - (unlink != null && unlink.visible ? 72 : 10); + status = fit(status, Math.max(0, right - font.width(title) - 30)); + graphics.text(font, status, right - font.width(status), 12, 0xFF404040, false); + graphics.text(font, "Channels", 10, 63, 0xFF404040, false); + String count = matches == channels.size() ? Integer.toString(channels.size()) : matches + " of " + channels.size(); + graphics.text(font, count, listWidth - 10 - font.width(count), 63, 0xFF404040, false); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + super.extractRenderState(graphics, mouseX, mouseY, partialTick); + } + + @Override + public boolean keyPressed(net.minecraft.client.input.KeyEvent event) { + int keyCode = event.key(); + if (keyCode == 256 && confirmingDelete) { + confirmingDelete = false; + updateActions(); + return true; + } + if (search.isFocused() && keyCode != 256 && keyCode != 258) { + if (keyCode == 264 && !list.children().isEmpty()) { + setFocused(list); + list.setFocused(list.getSelected() == null ? list.children().getFirst() : list.getSelected()); + return true; + } + if (keyCode == 257 || keyCode == 335) { + createChannel(); + return true; + } + return search.keyPressed(event) || search.canConsumeInput(); + } + return super.keyPressed(event); + } + + private final class ChannelList extends ObjectSelectionList { + private ChannelList(int x, int y, int width, int height) { + super(ChannelScreen.this.minecraft, width, height, y, 22); + setX(x); + } + + private void clearRows() { + setFocused(null); + clearEntries(); + } + + private void addRow(ChannelRow row) { + addEntry(row); + } + + private void revealSelection() { + scrollToEntry(getSelected()); + } + + @Override + protected void extractListBackground(GuiGraphicsExtractor graphics) { + InventoryPanels.inset(graphics, getX(), getY(), getWidth(), getHeight()); + } + + @Override + protected void extractListSeparators(GuiGraphicsExtractor graphics) { + InventoryPanels.insetBorder(graphics, getX(), getY(), getWidth(), getHeight()); + } + + @Override + public void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + super.extractWidgetRenderState(graphics, mouseX, mouseY, partialTick); + if (children().isEmpty()) { + graphics.textWithWordWrap(font, Component.literal("No matching channels"), getX() + 8, getY() + 8, getWidth() - 16, 0xFFFFFFFF); + } + } + + @Override + public int getRowWidth() { + return getWidth() - 16; + } + + @Override + protected int scrollBarX() { + return getRight() - 6; + } + + @Override + public void setSelected(ChannelRow row) { + super.setSelected(row); + if (row != null && row.channel != null) { + selectedName = row.channel.name(); + confirmingDelete = false; + if (choose != null) { + updateActions(); + } + } + } + } + + private final class ChannelRow extends ObjectSelectionList.Entry { + private final Channel channel; + private final String createName; + + private ChannelRow(Channel channel, String createName) { + this.channel = channel; + this.createName = createName; + } + + private String rowStatus() { + return channel == null || (!isCurrent(channel) && canChoose(channel)) ? "" + : isCurrent(channel) ? "selected" : unavailableLabel(); + } + + @Override + public void extractContent(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float partialTick) { + int x = getContentX(); + int y = getContentY(); + int width = getContentWidth(); + String status = rowStatus(); + String name = channel == null ? "+ Create \"" + createName + "\"" : channel.name(); + boolean atLimit = channel == null && channels.size() >= ChannelManagerPersistence.MAX_CHANNELS; + String shown = fit(name, width - font.width(status) - 12); + graphics.text(font, shown, x + 3, y + 6, atLimit ? 0xFFB0B0B0 : 0xFFFFFFFF, true); + graphics.text(font, status, x + width - font.width(status) - 3, y + 6, 0xFFFFFFFF, true); + if (hovered && atLimit) { + graphics.setTooltipForNextFrame(Component.literal("Channel limit reached (" + ChannelManagerPersistence.MAX_CHANNELS + ")"), mouseX, mouseY); + } else if (hovered && !shown.equals(name)) { + graphics.setTooltipForNextFrame(Component.literal(name), mouseX, mouseY); + } + } + + @Override + public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event, boolean doubleClick) { + int button = event.button(); + if (button != 0) { + return false; + } + if (channel == null) { + createChannel(); + } else { + list.setSelected(this); + } + return true; + } + + @Override + public boolean keyPressed(net.minecraft.client.input.KeyEvent event) { + int keyCode = event.key(); + if (channel == null && (keyCode == 257 || keyCode == 335 || keyCode == 32)) { + createChannel(); + return true; + } + return super.keyPressed(event); + } + + @Override + public Component getNarration() { + String status = rowStatus(); + return Component.literal(channel == null ? "Create " + createName : channel.name() + (status.isEmpty() ? "" : ", " + status)); + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/InventoryPanels.java b/glass/src/main/java/dev/imb11/client/gui/InventoryPanels.java new file mode 100644 index 0000000..fc0ad0d --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/InventoryPanels.java @@ -0,0 +1,29 @@ +package dev.imb11.client.gui; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; + +final class InventoryPanels { + private static final Identifier PANEL_SPRITE = Identifier.withDefaultNamespace("recipe_book/overlay_recipe"); + private static final Identifier INVENTORY_TEXTURE = Identifier.withDefaultNamespace("textures/gui/container/inventory.png"); + + private InventoryPanels() { + } + + static void panel(GuiGraphicsExtractor graphics, int x, int y, int width, int height) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, PANEL_SPRITE, x, y, width, height); + } + + static void inset(GuiGraphicsExtractor graphics, int x, int y, int width, int height) { + graphics.blit(RenderPipelines.GUI_TEXTURED, INVENTORY_TEXTURE, x + 1, y + 1, 8.0F, 84.0F, width - 2, height - 2, 1, 1, 256, 256); + insetBorder(graphics, x, y, width, height); + } + + static void insetBorder(GuiGraphicsExtractor graphics, int x, int y, int width, int height) { + graphics.blit(RenderPipelines.GUI_TEXTURED, INVENTORY_TEXTURE, x, y, 8.0F, 83.0F, width - 1, 1, 1, 1, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, INVENTORY_TEXTURE, x, y + 1, 7.0F, 84.0F, 1, height - 2, 1, 1, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, INVENTORY_TEXTURE, x + width - 1, y + 1, 24.0F, 84.0F, 1, height - 1, 1, 1, 256, 256); + graphics.blit(RenderPipelines.GUI_TEXTURED, INVENTORY_TEXTURE, x, y + height - 1, 8.0F, 100.0F, width - 1, 1, 1, 1, 256, 256); + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/ProjectorBlockGUI.java b/glass/src/main/java/dev/imb11/client/gui/ProjectorBlockGUI.java new file mode 100644 index 0000000..34b7ede --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/ProjectorBlockGUI.java @@ -0,0 +1,15 @@ +package dev.imb11.client.gui; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.platform.Platform; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.MenuType; + +public final class ProjectorBlockGUI extends ChannelMenu { + public static final MenuType SCREEN_HANDLER_TYPE = Platform.menuType(ProjectorBlockGUI::new, BlockPos.STREAM_CODEC); + + public ProjectorBlockGUI(int id, Inventory inventory, BlockPos pos) { + super(SCREEN_HANDLER_TYPE, id, inventory, pos, GBlocks.PROJECTOR); + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/ProjectorBlockScreen.java b/glass/src/main/java/dev/imb11/client/gui/ProjectorBlockScreen.java new file mode 100644 index 0000000..fd896e6 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/ProjectorBlockScreen.java @@ -0,0 +1,52 @@ +package dev.imb11.client.gui; + +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.platform.ClientNetworking; +import dev.imb11.sync.Channel; +import dev.imb11.sync.packets.C2SProjectorChannelChangedPacket; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; + +public final class ProjectorBlockScreen extends ChannelScreen { + public ProjectorBlockScreen(ProjectorBlockGUI menu, Inventory inventory, Component title) { + super(menu, inventory, title, false); + } + + @Override + protected boolean isCurrent(Channel channel) { + return minecraft.level.getBlockEntity(menu.pos) instanceof ProjectorBlockEntity projector + && channel.name().equals(projector.getChannel()); + } + + @Override + protected boolean canChoose(Channel channel) { + return channel.source() != null; + } + + @Override + protected void choose(Channel channel) { + ClientNetworking.send(new C2SProjectorChannelChangedPacket(menu.pos, channel.name())); + } + + @Override + protected Component chooseLabel() { + return Component.literal("Project this channel"); + } + + @Override + protected Component status(Channel channel) { + if (isCurrent(channel)) { + return Component.literal("Showing on this projector"); + } + if (channel.source() == null) { + return Component.literal("Not linked to a terminal"); + } + return Component.literal("Terminal at " + channel.source().pos().toShortString() + + " (" + channel.source().dimension().identifier() + ")"); + } + + @Override + protected String unavailableLabel() { + return "no terminal"; + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/TerminalBlockGUI.java b/glass/src/main/java/dev/imb11/client/gui/TerminalBlockGUI.java new file mode 100644 index 0000000..e7b9735 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/TerminalBlockGUI.java @@ -0,0 +1,15 @@ +package dev.imb11.client.gui; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.platform.Platform; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.MenuType; + +public final class TerminalBlockGUI extends ChannelMenu { + public static final MenuType SCREEN_HANDLER_TYPE = Platform.menuType(TerminalBlockGUI::new, BlockPos.STREAM_CODEC); + + public TerminalBlockGUI(int id, Inventory inventory, BlockPos pos) { + super(SCREEN_HANDLER_TYPE, id, inventory, pos, GBlocks.TERMINAL); + } +} diff --git a/glass/src/main/java/dev/imb11/client/gui/TerminalBlockScreen.java b/glass/src/main/java/dev/imb11/client/gui/TerminalBlockScreen.java new file mode 100644 index 0000000..87f25ac --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/gui/TerminalBlockScreen.java @@ -0,0 +1,58 @@ +package dev.imb11.client.gui; + +import dev.imb11.platform.ClientNetworking; +import dev.imb11.sync.Channel; +import dev.imb11.sync.packets.C2SRemoveLinkedChannelPacket; +import dev.imb11.sync.packets.C2STerminalChannelChangedPacket; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; + +public final class TerminalBlockScreen extends ChannelScreen { + public TerminalBlockScreen(TerminalBlockGUI menu, Inventory inventory, Component title) { + super(menu, inventory, title, true); + } + + @Override + protected boolean isCurrent(Channel channel) { + return channel.source() != null && minecraft.level != null + && channel.source().dimension().equals(minecraft.level.dimension()) + && channel.source().pos().equals(menu.pos); + } + + @Override + protected boolean canChoose(Channel channel) { + return channel.source() == null; + } + + @Override + protected void choose(Channel channel) { + ClientNetworking.send(new C2STerminalChannelChangedPacket(menu.pos, channel.name())); + } + + @Override + protected void unlink() { + ClientNetworking.send(new C2SRemoveLinkedChannelPacket(menu.pos)); + } + + @Override + protected Component chooseLabel() { + return Component.literal("Link to this channel"); + } + + @Override + protected Component status(Channel channel) { + if (isCurrent(channel)) { + return Component.literal("Linked to this terminal"); + } + if (channel.source() == null) { + return Component.literal("Available"); + } + return Component.literal("Used by terminal at " + channel.source().pos().toShortString() + + " (" + channel.source().dimension().identifier() + ")"); + } + + @Override + protected String unavailableLabel() { + return "in use"; + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/ProjectionChunkAccess.java b/glass/src/main/java/dev/imb11/client/remote/ProjectionChunkAccess.java new file mode 100644 index 0000000..279c6f3 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/ProjectionChunkAccess.java @@ -0,0 +1,5 @@ +package dev.imb11.client.remote; + +public interface ProjectionChunkAccess { + ProjectionChunkStorage glass$projectionChunks(); +} diff --git a/glass/src/main/java/dev/imb11/client/remote/ProjectionChunkStorage.java b/glass/src/main/java/dev/imb11/client/remote/ProjectionChunkStorage.java new file mode 100644 index 0000000..2f803f2 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/ProjectionChunkStorage.java @@ -0,0 +1,140 @@ +package dev.imb11.client.remote; + +import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.SectionPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +public final class ProjectionChunkStorage { + private final ClientChunkCache cache; + private final Map chunks = new ConcurrentHashMap<>(); + + public ProjectionChunkStorage(ClientChunkCache cache) { + this.cache = cache; + } + + public static ProjectionChunkStorage of(ClientLevel level) { + return ((ProjectionChunkAccess) level.getChunkSource()).glass$projectionChunks(); + } + + public LevelChunk get(int x, int z) { + Entry entry = chunks.get(ChunkPos.pack(x, z)); + return entry == null ? null : entry.chunk; + } + + public boolean contains(int x, int z) { + return chunks.containsKey(ChunkPos.pack(x, z)); + } + + public boolean retained(ChunkPos pos) { + Entry entry = chunks.get(pos.pack()); + return entry != null && entry.references > 0; + } + + public boolean vanilla(ChunkPos pos) { + Entry entry = chunks.get(pos.pack()); + return entry != null && entry.vanilla; + } + + public void retain(ChunkPos pos) { + Entry entry = chunks.get(pos.pack()); + if (entry == null) { + LevelChunk existing = cache.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, false); + entry = new Entry(existing, existing != null); + chunks.put(pos.pack(), entry); + } + entry.references++; + } + + public void release(ClientLevel level, ChunkPos pos) { + Entry entry = chunks.get(pos.pack()); + if (entry == null || entry.references < 1 || --entry.references > 0 || entry.vanilla) { + return; + } + chunks.remove(pos.pack()); + if (entry.chunk != null) { + level.unload(entry.chunk); + clearLight(level, pos); + } + } + + public LevelChunk replace(ClientLevel level, int x, int z, FriendlyByteBuf data, java.util.Map heightmaps, + Consumer blockEntities, boolean vanilla) { + Entry entry = chunks.get(ChunkPos.pack(x, z)); + if (entry == null) { + throw new IllegalStateException("Chunk has no projection owner"); + } + if (!vanilla && entry.vanilla && entry.chunk != null) { + return entry.chunk; + } + if (entry.chunk == null) { + entry.chunk = new LevelChunk(level, new ChunkPos(x, z)); + } + entry.vanilla |= vanilla; + entry.chunk.replaceWithPacketData(data, heightmaps, blockEntities); + level.onChunkLoaded(new ChunkPos(x, z)); + return entry.chunk; + } + + public boolean dropVanilla(ClientLevel level, ChunkPos pos) { + Entry entry = chunks.get(pos.pack()); + if (entry == null) { + return false; + } + entry.vanilla = false; + if (entry.references == 0) { + chunks.remove(pos.pack()); + if (entry.chunk != null) { + level.unload(entry.chunk); + } + } + return true; + } + + public boolean protectFromUnload(LevelChunk chunk) { + Entry entry = chunks.get(chunk.getPos().pack()); + if (entry == null || entry.chunk != chunk) { + return false; + } + if (entry.references > 0) { + entry.vanilla = false; + return true; + } + chunks.remove(chunk.getPos().pack()); + return false; + } + + private static void clearLight(ClientLevel level, ChunkPos pos) { + var light = level.getLightEngine(); + light.setLightEnabled(pos, false); + for (int y = light.getMinLightSection(); y < light.getMaxLightSection(); y++) { + SectionPos section = SectionPos.of(pos, y); + light.queueSectionData(LightLayer.SKY, section, null); + light.queueSectionData(LightLayer.BLOCK, section, null); + } + for (int y = level.getMinSectionY(); y <= level.getMaxSectionY(); y++) { + light.updateSectionStatus(SectionPos.of(pos, y), true); + } + } + + private static final class Entry { + private volatile LevelChunk chunk; + private int references; + private boolean vanilla; + + private Entry(LevelChunk chunk, boolean vanilla) { + this.chunk = chunk; + this.vanilla = vanilla; + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/RemoteClientScene.java b/glass/src/main/java/dev/imb11/client/remote/RemoteClientScene.java new file mode 100644 index 0000000..333752c --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/RemoteClientScene.java @@ -0,0 +1,688 @@ +package dev.imb11.client.remote; + +import com.mojang.logging.LogUtils; +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import dev.imb11.debug.RemoteSceneDiagnostics; +import dev.imb11.client.renderer.projection.RenderResources; +import dev.imb11.client.renderer.projection.ProjectionSections; +import dev.imb11.mixins.LevelRendererBufferAccessor; +import dev.imb11.sync.ProjectionSource; +import dev.imb11.sync.remote.RemoteSubscriptionId; +import dev.imb11.sync.remote.RemoteWorldState; +import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket; +import net.minecraft.network.protocol.game.ClientboundLightUpdatePacketData; +import dev.imb11.sync.remote.S2CRemoteSubscriptionPacket; +import dev.imb11.sync.remote.S2CRemoteEntitiesPacket; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.renderer.LevelRenderer; +import dev.imb11.client.renderer.projection.ProjectionLightmap; +import net.minecraft.client.renderer.RenderBuffers; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; +import dev.imb11.projection.ProjectionChunkRegion; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.lighting.LevelLightEngine; +import org.slf4j.Logger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +final class RemoteClientScene implements AutoCloseable { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final int STORAGE_VIEW_DISTANCE = 2; + private final boolean mainWorld; + private final ProjectionSource source; + private final Minecraft minecraft; + private final ResourceKey dimensionType; + private final boolean hardcore; + private final boolean debug; + private final boolean flat; + private final long seedHash; + private final ClientLevel.ClientLevelData levelData; + private final RenderBuffers sinkRenderBuffers; + private final RemoteSceneSinkRenderer sinkRenderer; + private final ClientLevel level; + private final ClientChunkCache chunkCache; + private final LevelLightEngine lightEngine; + private final ProjectionLightmap lightTexture; + private final RemoteSceneEntities entities; + private final Map> chunkOwners = new LinkedHashMap<>(); + private final Map> renderers = new LinkedHashMap<>(); + private final LinkedHashSet deferredLightSections = new LinkedHashSet<>(); + private final Map> pendingDirtySections = new IdentityHashMap<>(); + private RemoteWorldState state; + private int skyFlashTime; + private boolean applying; + private boolean closed; + + private RemoteClientScene( + ProjectionSource source, + Minecraft minecraft, + ResourceKey dimensionType, + boolean hardcore, + boolean debug, + boolean flat, + long seedHash, + ClientLevel.ClientLevelData levelData, + RenderBuffers sinkRenderBuffers, + RemoteSceneSinkRenderer sinkRenderer, + ClientLevel level, + ProjectionLightmap lightTexture, + RemoteWorldState state + ) { + this.source = source; + this.minecraft = minecraft; + this.dimensionType = dimensionType; + this.hardcore = hardcore; + this.debug = debug; + this.flat = flat; + this.seedHash = seedHash; + this.levelData = levelData; + this.sinkRenderBuffers = sinkRenderBuffers; + this.sinkRenderer = sinkRenderer; + this.level = level; + this.mainWorld = level == minecraft.level; + this.chunkCache = level.getChunkSource(); + this.lightEngine = chunkCache.getLightEngine(); + this.lightTexture = lightTexture; + this.entities = new RemoteSceneEntities(level); + this.state = state; + } + + static RemoteClientScene create(Minecraft minecraft, S2CRemoteSubscriptionPacket packet) { + ClientPacketListener connection = Objects.requireNonNull(minecraft.getConnection()); + Holder dimensionType = connection.registryAccess() + .lookupOrThrow(Registries.DIMENSION_TYPE) + .getOrThrow(packet.dimensionType()); + if (minecraft.level != null && minecraft.level.dimension().equals(packet.subscription().source().dimension())) { + return new RemoteClientScene(packet.subscription().source(), minecraft, packet.dimensionType(), + packet.hardcore(), packet.debug(), packet.flat(), packet.seedHash(), null, null, null, + minecraft.level, new ProjectionLightmap(), packet.state()); + } + RenderBuffers renderBuffers = new RenderBuffers(1); + RemoteSceneSinkRenderer renderer = null; + ProjectionLightmap lightTexture = null; + try { + renderer = new RemoteSceneSinkRenderer(minecraft, renderBuffers); + ClientLevel.ClientLevelData levelData = new ClientLevel.ClientLevelData(packet.difficulty(), packet.hardcore(), packet.flat()); + ClientLevel level = new RemoteSceneLevel( + connection, + levelData, + packet.subscription().source().dimension(), + dimensionType, + STORAGE_VIEW_DISTANCE, + renderer, + packet.debug(), + packet.seedHash(), + packet.seaLevel() + ); + ChunkPos sourceChunk = ChunkPos.containing(packet.subscription().source().pos()); + level.getChunkSource().updateViewCenter(sourceChunk.x(), sourceChunk.z()); + level.getChunkSource().updateViewRadius(STORAGE_VIEW_DISTANCE); + lightTexture = new ProjectionLightmap(); + RemoteClientScene scene = new RemoteClientScene( + packet.subscription().source(), + minecraft, + packet.dimensionType(), + packet.hardcore(), + packet.debug(), + packet.flat(), + packet.seedHash(), + levelData, + renderBuffers, + renderer, + level, + lightTexture, + packet.state() + ); + renderer.bind(scene); + scene.applyWorldState(packet.state()); + ProjectionRenderContext.registerRemoteLightUpdates(scene.chunkCache, scene::onLightUpdate); + return scene; + } catch (RuntimeException exception) { + if (lightTexture != null) { + ProjectionLightmap failedProjectionLightmap = lightTexture; + cleanupCreation("light texture", () -> closeProjectionLightmap(minecraft, failedProjectionLightmap)); + } + if (renderer != null) { + RemoteSceneSinkRenderer failedRenderer = renderer; + cleanupCreation("sink renderer", failedRenderer::close); + } + cleanupCreation("render buffers", () -> closeRenderBuffers(renderBuffers)); + throw exception; + } + } + + ProjectionSource source() { + return source; + } + + ClientLevel level() { + return level; + } + + ProjectionLightmap lightTexture() { + return lightTexture; + } + + boolean matches(S2CRemoteSubscriptionPacket packet) { + return source.dimension().equals(packet.subscription().source().dimension()) + && dimensionType.equals(packet.dimensionType()) + && hardcore == packet.hardcore() + && debug == packet.debug() + && flat == packet.flat() + && seedHash == packet.seedHash() + && level.getSeaLevel() == packet.seaLevel(); + } + + void attachRenderer(RemoteSubscriptionId subscription, LevelRenderer renderer) { + if (closed) { + return; + } + boolean alreadyAttached = renderers.values().stream().anyMatch(attached -> attached.contains(renderer)); + renderers.computeIfAbsent(subscription, ignored -> Collections.newSetFromMap(new IdentityHashMap<>())).add(renderer); + if (!alreadyAttached && rendererActive(renderer)) { + for (Map.Entry> entry : chunkOwners.entrySet()) { + if (entry.getValue().contains(subscription)) { + notifyRendererChunk(renderer, ChunkPos.unpack(entry.getKey())); + } + } + } + } + + void detachRenderer(RemoteSubscriptionId subscription, LevelRenderer renderer) { + Set attached = renderers.get(subscription); + if (attached != null && attached.remove(renderer) && attached.isEmpty()) { + renderers.remove(subscription); + } + } + + void setRegion(RemoteSubscriptionId subscription, ChunkPos center, int radius) { + Set next = new ProjectionChunkRegion(center, radius).withNeighbors().chunks(); + for (ChunkPos pos : next) { + LinkedHashSet owners = chunkOwners.computeIfAbsent(pos.pack(), ignored -> new LinkedHashSet<>()); + if (owners.isEmpty()) { + ProjectionChunkStorage.of(level).retain(pos); + } + owners.add(subscription); + } + for (long packed : List.copyOf(chunkOwners.keySet())) { + ChunkPos pos = ChunkPos.unpack(packed); + if (!next.contains(pos)) { + unloadChunk(subscription, pos); + } + } + } + + void applyChunk(RemoteSubscriptionId subscription, ClientboundLevelChunkWithLightPacket packet) { + ChunkPos pos = new ChunkPos(packet.getX(), packet.getZ()); + if (closed || !ownedBy(subscription, pos)) { + return; + } + ProjectionChunkStorage storage = ProjectionChunkStorage.of(level); + if (storage.vanilla(pos)) { + return; + } + beginApply(); + boolean committed = false; + try { + var data = packet.getChunkData(); + FriendlyByteBuf buffer = data.getReadBuffer(); + LevelChunk chunk; + try { + chunk = storage.replace(level, pos.x(), pos.z(), buffer, data.getHeightmaps(), + data.getBlockEntitiesTagsConsumer(pos.x(), pos.z()), false); + } finally { + buffer.release(); + } + applyLightLayers(pos, packet.getLightData()); + LevelChunkSection[] sections = chunk.getSections(); + for (int index = 0; index < sections.length; index++) { + lightEngine.updateSectionStatus(SectionPos.of(pos, level.getSectionYFromSectionIndex(index)), sections[index].hasOnlyAir()); + } + chunk.setLightCorrect(true); + committed = true; + } finally { + finishApply(committed); + } + if (committed) { + notifyOwnersChunk(pos); + } + } + + void unloadChunk(RemoteSubscriptionId subscription, ChunkPos pos) { + LinkedHashSet owners = chunkOwners.get(pos.pack()); + if (owners == null || !owners.contains(subscription)) { + return; + } + if (owners.size() == 1) { + if (!ProjectionChunkStorage.of(level).vanilla(pos)) { + resetOwnersChunk(pos); + } + chunkOwners.remove(pos.pack()); + ProjectionChunkStorage.of(level).release(level, pos); + } else { + owners.remove(subscription); + } + } + + void applyBlockUpdates( + RemoteSubscriptionId subscription, + int sectionX, + int sectionY, + int sectionZ, + ClientboundSectionBlocksUpdatePacket updates + ) { + ChunkPos pos = new ChunkPos(sectionX, sectionZ); + if (closed || !ownedBy(subscription, pos) || ProjectionChunkStorage.of(level).vanilla(pos) + || sectionY < level.getMinSectionY() || sectionY > level.getMaxSectionY()) { + return; + } + updates.runUpdates((block, state) -> { + if (SectionPos.blockToSectionCoord(block.getX()) == sectionX + && SectionPos.blockToSectionCoord(block.getY()) == sectionY + && SectionPos.blockToSectionCoord(block.getZ()) == sectionZ) { + level.setServerVerifiedBlockState(block, state, 19); + } + }); + } + + void applyLight(RemoteSubscriptionId subscription, ChunkPos pos, ClientboundLightUpdatePacketData light) { + if (!ownedBy(subscription, pos) || ProjectionChunkStorage.of(level).vanilla(pos)) { + return; + } + beginApply(); + boolean committed = false; + try { + applyLightLayers(pos, light); + committed = true; + } finally { + finishApply(committed); + } + } + + void applyWorldState(RemoteWorldState state) { + if (closed) { + return; + } + this.state = state; + if (mainWorld) { + return; + } + level.setTimeFromServer(state.time().gameTime()); + level.clockManager().handleUpdates(state.time().gameTime(), state.time().clockUpdates()); + level.environmentAttributes().invalidateTickCache(); + level.setRainLevel(state.rainLevel()); + level.setThunderLevel(state.thunderLevel()); + skyFlashTime = state.skyFlashTime(); + level.setSkyFlashTime(skyFlashTime); + level.updateSkyBrightness(); + } + + void advanceClock() { + if (closed || mainWorld || state == null) { + return; + } + level.setTimeFromServer(level.getGameTime() + 1L); + level.clockManager().tick(level.getGameTime()); + level.environmentAttributes().invalidateTickCache(); + if (skyFlashTime > 0) { + level.setSkyFlashTime(--skyFlashTime); + } + level.updateSkyBrightness(); + entities.tick(); + } + + void onVanillaEntityAdded(net.minecraft.world.entity.Entity entity) { + entities.onVanillaAdded(entity); + } + + boolean onVanillaEntityRemoved(int id) { + return entities.onVanillaRemoved(id); + } + + void applyEntities(RemoteSubscriptionId subscription, List messages) { + if (!closed) { + entities.apply(subscription, messages); + } + } + + void applyBlockEntities(RemoteSubscriptionId subscription, List updates) { + for (ClientboundBlockEntityDataPacket update : updates) { + BlockPos pos = update.getPos(); + if (closed || !ownedBy(subscription, ChunkPos.containing(pos)) || ProjectionChunkStorage.of(level).vanilla(ChunkPos.containing(pos))) { + continue; + } + var blockEntity = level.getBlockEntity(pos); + if (blockEntity != null && blockEntity.getType() == update.getType()) { + blockEntity.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(net.minecraft.util.ProblemReporter.DISCARDING, level.registryAccess(), update.getTag())); + dirtyOwnersSection(SectionPos.blockToSectionCoord(pos.getX()), SectionPos.blockToSectionCoord(pos.getY()), + SectionPos.blockToSectionCoord(pos.getZ()), false); + } + } + } + + boolean isReady(ChunkPos cameraCenter) { + return isReady(cameraCenter, 1); + } + + boolean isReady(ChunkPos center, int radius) { + if (closed) { + return false; + } + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + ChunkPos pos = new ChunkPos(center.x() + offsetX, center.z() + offsetZ); + if (!chunkPresent(pos) || !chunkLit(pos)) { + return false; + } + } + } + return true; + } + + RemoteSceneDiagnostics.ChunkStats chunkDiagnostics(ChunkPos center, int radius) { + long sharedSubscriptions = chunkOwners.values().stream().flatMap(Set::stream).distinct().count(); + return RemoteSceneDiagnostics.ChunkStats.scan(mainWorld, sharedSubscriptions, closed, center, radius, + this::chunkPresent, this::chunkLit); + } + + private boolean chunkPresent(ChunkPos pos) { + return chunkCache.getChunk(pos.x(), pos.z(), ChunkStatus.FULL, false) != null && chunkOwners.containsKey(pos.pack()); + } + + private boolean chunkLit(ChunkPos pos) { + return lightEngine.lightOnInColumn(SectionPos.of(pos, 0).asLong()); + } + + void releaseSubscription(RemoteSubscriptionId subscription) { + entities.release(subscription); + List owned = new ArrayList<>(); + for (Map.Entry> entry : chunkOwners.entrySet()) { + if (entry.getValue().contains(subscription)) { + owned.add(ChunkPos.unpack(entry.getKey())); + } + } + for (ChunkPos pos : owned) { + unloadChunk(subscription, pos); + } + } + + void dirtyBlock(BlockPos pos, BlockState oldState, BlockState newState) { + if (!applying && !closed) { + dirtyBlocks(pos.getX(), pos.getY(), pos.getZ(), pos.getX(), pos.getY(), pos.getZ()); + } + } + + void dirtyBlocks(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + if (applying || closed) { + return; + } + int minSectionX = SectionPos.blockToSectionCoord(minX - 1); + int minSectionY = SectionPos.blockToSectionCoord(minY - 1); + int minSectionZ = SectionPos.blockToSectionCoord(minZ - 1); + int maxSectionX = SectionPos.blockToSectionCoord(maxX + 1); + int maxSectionY = SectionPos.blockToSectionCoord(maxY + 1); + int maxSectionZ = SectionPos.blockToSectionCoord(maxZ + 1); + for (int sectionX = minSectionX; sectionX <= maxSectionX; sectionX++) { + for (int sectionY = minSectionY; sectionY <= maxSectionY; sectionY++) { + for (int sectionZ = minSectionZ; sectionZ <= maxSectionZ; sectionZ++) { + dirtyOwnersSection(sectionX, sectionY, sectionZ, false); + } + } + } + } + + void dirtySectionWithNeighbors(int sectionX, int sectionY, int sectionZ) { + if (!applying && !closed) { + dirtyOwnersSection(sectionX, sectionY, sectionZ, true); + } + } + + void dirtySection(int sectionX, int sectionY, int sectionZ) { + if (!applying && !closed) { + dirtyOwnersSection(sectionX, sectionY, sectionZ, false); + } + } + + void chunkLoaded(ChunkPos pos) { + if (!applying && !closed) { + notifyOwnersChunk(pos); + } + } + + private void applyLightLayers(ChunkPos pos, ClientboundLightUpdatePacketData light) { + int sectionCount = lightEngine.getLightSectionCount(); + BitSet skyMask = light.getSkyYMask(); + BitSet emptySkyMask = light.getEmptySkyYMask(); + BitSet blockMask = light.getBlockYMask(); + BitSet emptyBlockMask = light.getEmptyBlockYMask(); + if (skyMask.length() > sectionCount + || emptySkyMask.length() > sectionCount + || blockMask.length() > sectionCount + || emptyBlockMask.length() > sectionCount) { + throw new IllegalArgumentException("remote light section count mismatch"); + } + applyLightLayer(pos, LightLayer.SKY, skyMask, emptySkyMask, light.getSkyUpdates()); + applyLightLayer(pos, LightLayer.BLOCK, blockMask, emptyBlockMask, light.getBlockUpdates()); + lightEngine.setLightEnabled(pos, true); + } + + private void applyLightLayer(ChunkPos pos, LightLayer layer, BitSet dataMask, BitSet emptyMask, List updates) { + Iterator updateIterator = updates.iterator(); + for (int index = 0; index < lightEngine.getLightSectionCount(); index++) { + boolean hasData = dataMask.get(index); + if (!hasData && !emptyMask.get(index)) { + continue; + } + DataLayer data = hasData ? new DataLayer(updateIterator.next()) : new DataLayer(); + int sectionY = lightEngine.getMinLightSection() + index; + lightEngine.queueSectionData(layer, SectionPos.of(pos, sectionY), data); + } + if (updateIterator.hasNext()) { + throw new IllegalArgumentException("remote light update count mismatch"); + } + } + + private void beginApply() { + applying = true; + deferredLightSections.clear(); + } + + private void finishApply(boolean committed) { + applying = false; + if (!committed) { + deferredLightSections.clear(); + return; + } + for (long section : deferredLightSections) { + SectionPos pos = SectionPos.of(section); + dirtyOwnersSection(pos.x(), pos.y(), pos.z(), true); + } + deferredLightSections.clear(); + } + + private void onLightUpdate(LightLayer layer, SectionPos sectionPos) { + if (closed) { + return; + } + if (applying) { + deferredLightSections.add(sectionPos.asLong()); + } else { + dirtyOwnersSection(sectionPos.x(), sectionPos.y(), sectionPos.z(), true); + } + } + + private boolean ownedBy(RemoteSubscriptionId subscription, ChunkPos pos) { + LinkedHashSet owners = chunkOwners.get(pos.pack()); + return owners != null && owners.contains(subscription); + } + + private void notifyOwnersChunk(ChunkPos pos) { + for (LevelRenderer renderer : ownerRenderers(pos)) { + notifyRendererChunk(renderer, pos); + } + } + + private void notifyRendererChunk(LevelRenderer renderer, ChunkPos pos) { + renderer.onChunkReadyToRender(pos); + queueDirtyChunk(renderer, pos, true); + } + + private void queueDirtyChunk(LevelRenderer renderer, ChunkPos pos, boolean neighbors) { + int padding = neighbors ? 1 : 0; + for (int x = pos.x() - padding; x <= pos.x() + padding; x++) { + for (int z = pos.z() - padding; z <= pos.z() + padding; z++) { + for (int y = level.getMinSectionY(); y <= level.getMaxSectionY(); y++) { + queueDirtySection(renderer, x, y, z, false); + } + } + } + } + + private void dirtyOwnersSection(int sectionX, int sectionY, int sectionZ, boolean neighbors) { + ChunkPos chunk = new ChunkPos(sectionX, sectionZ); + for (LevelRenderer renderer : ownerRenderers(chunk)) { + queueDirtySection(renderer, sectionX, sectionY, sectionZ, neighbors); + } + } + + private void queueDirtySection(LevelRenderer renderer, int sectionX, int sectionY, int sectionZ, boolean neighbors) { + int padding = neighbors ? 1 : 0; + for (int x = sectionX - padding; x <= sectionX + padding; x++) { + for (int y = sectionY - padding; y <= sectionY + padding; y++) { + for (int z = sectionZ - padding; z <= sectionZ + padding; z++) { + if (ProjectionSections.find(renderer, x, y, z) != null) { + pendingDirtySections.computeIfAbsent(renderer, ignored -> new LinkedHashSet<>()).add(SectionPos.asLong(x, y, z)); + } + } + } + } + } + + void flushUpdates() { + if (closed) { + return; + } + if (!mainWorld && lightEngine.hasLightWork()) { + lightEngine.runLightUpdates(); + } + for (Map.Entry> entry : pendingDirtySections.entrySet()) { + for (long packed : entry.getValue()) { + SectionPos pos = SectionPos.of(packed); + ProjectionSections.markDirty(entry.getKey(), pos.x(), pos.y(), pos.z()); + } + } + pendingDirtySections.clear(); + } + + private Set ownerRenderers(ChunkPos pos) { + LinkedHashSet owners = chunkOwners.get(pos.pack()); + if (owners == null || owners.isEmpty()) { + return Set.of(); + } + Set result = Collections.newSetFromMap(new IdentityHashMap<>()); + for (RemoteSubscriptionId owner : owners) { + for (LevelRenderer renderer : renderers.getOrDefault(owner, Set.of())) { + if (rendererActive(renderer)) { + result.add(renderer); + } + } + } + return result; + } + + private static boolean rendererActive(LevelRenderer renderer) { + return ((LevelRendererBufferAccessor) renderer).glass$getViewArea() != null; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + pendingDirtySections.clear(); + cleanup("entities", entities::clear); + cleanup("chunk and light storage", () -> { + for (long packed : List.copyOf(chunkOwners.keySet())) { + ProjectionChunkStorage.of(level).release(level, ChunkPos.unpack(packed)); + } + }); + if (!mainWorld) { + ProjectionRenderContext.unregisterRemoteLightUpdates(chunkCache); + try { + level.close(); + } catch (IOException | RuntimeException exception) { + LOGGER.warn("Failed to close remote client level {}", source.key(), exception); + } + } + chunkOwners.clear(); + renderers.clear(); + deferredLightSections.clear(); + if (mainWorld) { + return; + } + cleanup("tint caches", level::clearTintCaches); + cleanup("light texture", () -> closeProjectionLightmap(minecraft, lightTexture)); + cleanup("sink renderer", sinkRenderer::close); + cleanup("render buffers", () -> closeRenderBuffers(sinkRenderBuffers)); + } + + private void resetOwnersChunk(ChunkPos pos) { + for (LevelRenderer renderer : ownerRenderers(pos)) { + ProjectionSections.resetColumn(renderer, level, pos); + } + } + + private static void closeProjectionLightmap(Minecraft minecraft, ProjectionLightmap lightTexture) { + lightTexture.close(); + } + + private void cleanup(String resource, Runnable operation) { + try { + operation.run(); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to close remote scene {} {}", source.key(), resource, exception); + } + } + + private static void cleanupCreation(String resource, Runnable operation) { + try { + operation.run(); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to close incomplete remote scene {}", resource, exception); + } + } + + private static void closeRenderBuffers(RenderBuffers renderBuffers) { + RenderResources.closeBufferBuilders(renderBuffers); + RenderResources.closeAvailablePoolBuffers(renderBuffers.sectionBufferPool()); + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/RemoteSceneClientManager.java b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneClientManager.java new file mode 100644 index 0000000..899d1f4 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneClientManager.java @@ -0,0 +1,626 @@ +package dev.imb11.client.remote; + +import com.mojang.logging.LogUtils; +import dev.imb11.sync.remote.C2SRemoteSubscribePacket; +import dev.imb11.debug.RemoteSceneDiagnostics; +import dev.imb11.sync.remote.C2SRemoteUnsubscribePacket; +import dev.imb11.sync.remote.C2SRemoteUpdatePacket; +import dev.imb11.sync.remote.C2SRemoteChunkAckPacket; +import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.minecraft.world.entity.Entity; +import net.minecraft.network.protocol.game.ClientboundLightUpdatePacketData; +import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket; +import dev.imb11.sync.remote.RemoteSubscriptionId; +import dev.imb11.sync.remote.RemoteSceneServerManager; +import dev.imb11.sync.remote.RemoteWorldState; +import dev.imb11.sync.remote.S2CRemoteBlockUpdatesPacket; +import dev.imb11.sync.remote.S2CRemoteChunkPacket; +import dev.imb11.sync.remote.S2CRemoteEntitiesPacket; +import dev.imb11.sync.remote.S2CRemoteBlockEntitiesPacket; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import dev.imb11.sync.remote.S2CRemoteLightPacket; +import dev.imb11.sync.remote.S2CRemoteSubscriptionPacket; +import dev.imb11.sync.remote.S2CRemoteUnavailablePacket; +import dev.imb11.sync.remote.S2CRemoteUnloadPacket; +import dev.imb11.sync.remote.S2CRemoteWorldStatePacket; +import dev.imb11.platform.ClientNetworking; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.world.level.ChunkPos; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class RemoteSceneClientManager { + public static final int MAX_PENDING_PER_SUBSCRIPTION = 64; + public static final int MAX_PENDING_GLOBAL = 256; + public static final int MAX_APPLIES_PER_TICK = 8; + public static final long MAX_PENDING_BYTES_PER_SUBSCRIPTION = 8L * 1024L * 1024L; + public static final long MAX_PENDING_BYTES_GLOBAL = 32L * 1024L * 1024L; + private static final Logger LOGGER = LogUtils.getLogger(); + private static final int HEARTBEAT_TICKS = 40; + private static final Map SUBSCRIPTIONS = new LinkedHashMap<>(); + private static final Map, RemoteClientScene> SCENES = new LinkedHashMap<>(); + private static final ArrayDeque PENDING = new ArrayDeque<>(); + private static final ArrayDeque ACKNOWLEDGEMENTS = new ArrayDeque<>(); + private static final long APPLY_BUDGET_NANOS = 2_000_000L; + private static long clientTicks; + private static long pendingBytes; + private static ClientLevel mainLevel; + private static boolean receiversRegistered; + + private RemoteSceneClientManager() { + } + + public static void registerReceivers() { + if (receiversRegistered) { + return; + } + receiversRegistered = true; + ClientNetworking.registerReceiver(S2CRemoteSubscriptionPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteUnavailablePacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteChunkPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteEntitiesPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteBlockEntitiesPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteUnloadPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteBlockUpdatesPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteLightPacket.PACKET_ID, RemoteSceneClientManager::accept); + ClientNetworking.registerReceiver(S2CRemoteWorldStatePacket.PACKET_ID, RemoteSceneClientManager::accept); + } + + public static RemoteSceneHandle acquire(RemoteSubscriptionId subscription, ChunkPos cameraCenter, int requestedRadius) { + Objects.requireNonNull(subscription); + Objects.requireNonNull(cameraCenter); + int radius = clampRadius(requestedRadius); + RemoteSceneHandle state = SUBSCRIPTIONS.get(subscription); + if (state != null) { + updateState(state, cameraCenter, radius); + return state; + } + state = new RemoteSceneHandle(subscription, cameraCenter, radius); + SUBSCRIPTIONS.put(subscription, state); + state.subscribed = send(new C2SRemoteSubscribePacket(subscription, cameraCenter, radius)); + state.lastSendTick = clientTicks; + LOGGER.info("[GLASS projector] client subscribe id={} center={} radius={} sent={}", subscription, cameraCenter, radius, state.subscribed); + return state; + } + + public static void update(RemoteSceneHandle handle, ChunkPos cameraCenter, int requestedRadius) { + Objects.requireNonNull(cameraCenter); + if (isActive(handle)) { + updateState(handle, cameraCenter, clampRadius(requestedRadius)); + } + } + + public static void release(RemoteSceneHandle handle) { + if (handle == null || !SUBSCRIPTIONS.remove(handle.id, handle)) { + return; + } + LOGGER.info("[GLASS projector] client release id={} pending={} pendingBytes={} lastSequence={} unavailable={}", + handle.id, handle.pendingCount, handle.pendingBytes, handle.highestSequence, handle.unavailable); + if (handle.subscribed) { + send(new C2SRemoteUnsubscribePacket(handle.id)); + } + removePending(handle); + detachState(handle); + } + + public static void attachRenderer(RemoteSceneHandle handle, LevelRenderer renderer) { + Objects.requireNonNull(renderer); + if (isActive(handle) && handle.renderers.merge(renderer, 1, Integer::sum) == 1 && handle.scene != null) { + handle.scene.attachRenderer(handle.id, renderer); + } + } + + public static void detachRenderer(RemoteSceneHandle handle, LevelRenderer renderer) { + Objects.requireNonNull(renderer); + Integer count = isActive(handle) ? handle.renderers.get(renderer) : null; + if (count == null) { + return; + } + if (count > 1) { + handle.renderers.put(renderer, count - 1); + return; + } + handle.renderers.remove(renderer); + if (handle.scene != null) { + handle.scene.detachRenderer(handle.id, renderer); + } + } + + public static void tick(Minecraft minecraft) { + clientTicks++; + if (!minecraft.isPaused()) { + for (RemoteClientScene scene : List.copyOf(SCENES.values())) { + try { + scene.advanceClock(); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to tick remote scene {}", scene.source().key(), exception); + failScene(scene, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + } + } + } + int applied = 0; + long applyStarted = System.nanoTime(); + while (applied < MAX_APPLIES_PER_TICK && System.nanoTime() - applyStarted < APPLY_BUDGET_NANOS) { + PendingApply pending = PENDING.pollFirst(); + if (pending == null) { + break; + } + accountDequeued(pending.state, pending.command.bytes()); + RemoteSceneHandle current = SUBSCRIPTIONS.get(pending.state.id); + if (current == pending.state && current.scene != null && !current.terminalFailure) { + try { + pending.command.apply(current); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to apply remote scene payload {}", current.id, exception); + failScene(current.scene, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + } + } else { + pending.state.discardedApplies++; + } + applied++; + } + for (RemoteClientScene scene : List.copyOf(SCENES.values())) { + try { + scene.flushUpdates(); + } catch (RuntimeException exception) { + LOGGER.warn("[GLASS projector] failed to finish remote scene updates {}", scene.source().key(), exception); + failScene(scene, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + } + } + while (!ACKNOWLEDGEMENTS.isEmpty()) { + send(ACKNOWLEDGEMENTS.removeFirst()); + } + for (RemoteSceneHandle state : List.copyOf(SUBSCRIPTIONS.values())) { + if (state.terminalFailure || clientTicks - state.lastSendTick < HEARTBEAT_TICKS) { + continue; + } + if (!state.subscribed) { + state.subscribed = send(new C2SRemoteSubscribePacket(state.id, state.requestedCenter, state.requestedRadius)); + } else { + send(new C2SRemoteUpdatePacket(state.id, state.requestedCenter, state.requestedRadius)); + } + state.lastSendTick = clientTicks; + } + } + + public static void onVanillaEntityAdded(ClientLevel level, Entity entity) { + RemoteClientScene scene = SCENES.get(level.dimension()); + if (scene != null && scene.level() == level) { + scene.onVanillaEntityAdded(entity); + } + } + + public static boolean onVanillaEntityRemoved(ClientLevel level, int id) { + RemoteClientScene scene = SCENES.get(level.dimension()); + return scene != null && scene.level() == level && scene.onVanillaEntityRemoved(id); + } + + public static void onMainLevelChanged(@Nullable ClientLevel level) { + if (mainLevel == level) { + return; + } + if (mainLevel != null || level == null) { + clear(true); + } + mainLevel = level; + } + + public static void onResourceReload() { + clear(true); + } + + public static void clearConnection() { + clear(false); + mainLevel = null; + } + + static boolean isActive(RemoteSceneHandle handle) { + return handle != null && SUBSCRIPTIONS.get(handle.id) == handle; + } + + static Object diagnostics(RemoteSceneHandle state) { + if (!isActive(state)) { + return "epoch=" + state.id.epoch() + " state=released"; + } + return new RemoteSceneDiagnostics(state.id.epoch(), state.subscribed, state.unavailable, state.terminalFailure, + state.requestedCenter, state.requestedRadius, state.grantedCenter, state.grantedRadius, + state.withinGrant(state.requestedCenter, 0), state.pendingCount, state.pendingBytes, PENDING.size(), pendingBytes, + state.highestSequence, state.stalePackets, state.discardedApplies, clientTicks - state.lastSendTick, + state.scene == null || state.grantedCenter == null ? null + : state.scene.chunkDiagnostics(state.grantedCenter, state.grantedRadius + 1)); + } + + private static void accept(S2CRemoteSubscriptionPacket packet) { + RemoteSceneHandle state = SUBSCRIPTIONS.get(packet.subscription()); + if (state == null || state.terminalFailure) { + return; + } + RemoteClientScene scene = SCENES.get(state.id.source().dimension()); + if (scene != null && !scene.matches(packet)) { + fail(state, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED, true); + return; + } + if (scene == null) { + try { + scene = RemoteClientScene.create(Minecraft.getInstance(), packet); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to create remote scene {}", state.id.source().key(), exception); + fail(state, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED, true); + return; + } + SCENES.put(state.id.source().dimension(), scene); + } + if (state.scene != scene) { + if (state.scene != null) { + detachStateScene(state); + } + state.scene = scene; + for (LevelRenderer renderer : state.renderers.keySet()) { + scene.attachRenderer(state.id, renderer); + } + } + state.grantedCenter = packet.grantedCenter(); + state.grantedRadius = packet.grantedRadius(); + state.unavailable = null; + state.subscribed = true; + scene.setRegion(state.id, state.grantedCenter, state.grantedRadius); + scene.applyWorldState(packet.state()); + if (state.grantLog.ready(System.nanoTime())) { + LOGGER.info("[GLASS projector] client grant id={} center={} radius={} requestedCenter={} requestedRadius={}", + state.id, state.grantedCenter, state.grantedRadius, state.requestedCenter, state.requestedRadius); + } + } + + private static void accept(S2CRemoteUnavailablePacket packet) { + RemoteSceneHandle state = SUBSCRIPTIONS.get(packet.subscription()); + if (state == null) { + return; + } + boolean terminal = packet.reason() != S2CRemoteUnavailablePacket.Reason.BUDGET; + fail(state, packet.reason(), terminal); + } + + private static void accept(S2CRemoteChunkPacket packet) { + enqueue(packet.subscription(), new ChunkUpdate(packet.sequence(), + new ChunkPos(packet.data().getX(), packet.data().getZ()), packet.data(), packet.encodedBytes())); + } + + private static void accept(S2CRemoteUnloadPacket packet) { + enqueue(packet.subscription(), new UnloadUpdate(packet.sequence(), new ChunkPos(packet.chunkX(), packet.chunkZ()))); + } + + private static void accept(S2CRemoteEntitiesPacket packet) { + enqueue(packet.subscription(), new EntityUpdates(packet.sequence(), packet.messages())); + } + + private static void accept(S2CRemoteBlockEntitiesPacket packet) { + enqueue(packet.subscription(), new BlockEntityUpdates(packet.sequence(), packet.updates())); + } + + private static void accept(S2CRemoteBlockUpdatesPacket packet) { + enqueue(packet.subscription(), new BlockUpdate( + packet.sequence(), + packet.sectionX(), + packet.sectionY(), + packet.sectionZ(), + packet.updates() + )); + } + + private static void accept(S2CRemoteLightPacket packet) { + enqueue(packet.subscription(), new LightUpdate( + packet.sequence(), + new ChunkPos(packet.data().getX(), packet.data().getZ()), + packet.data().getLightData() + )); + } + + private static void accept(S2CRemoteWorldStatePacket packet) { + enqueue(packet.subscription(), new WorldStateUpdate(packet.sequence(), packet.state())); + } + + private static void enqueue(RemoteSubscriptionId id, SceneUpdate command) { + RemoteSceneHandle state = SUBSCRIPTIONS.get(id); + if (state == null || state.terminalFailure) { + return; + } + if (command.sequence() <= state.highestSequence) { + state.stalePackets++; + return; + } + state.highestSequence = command.sequence(); + if (state.pendingCount >= MAX_PENDING_PER_SUBSCRIPTION + || PENDING.size() >= MAX_PENDING_GLOBAL + || state.pendingBytes + command.bytes() > MAX_PENDING_BYTES_PER_SUBSCRIPTION + || pendingBytes + command.bytes() > MAX_PENDING_BYTES_GLOBAL) { + fail(state, S2CRemoteUnavailablePacket.Reason.BUDGET, true); + return; + } + if (command instanceof ChunkControl control) { + state.recordControl(control.chunk(), command.sequence(), control.loads()); + } + PENDING.addLast(new PendingApply(state, command)); + state.pendingCount++; + state.pendingBytes += command.bytes(); + pendingBytes += command.bytes(); + } + + private static int clampRadius(int requestedRadius) { + return Math.clamp(requestedRadius, RemoteSceneServerManager.MIN_RADIUS, RemoteSceneServerManager.MAX_RADIUS); + } + + private static void updateState(RemoteSceneHandle state, ChunkPos cameraCenter, int radius) { + if (state.requestedCenter.equals(cameraCenter) && state.requestedRadius == radius) { + return; + } + state.requestedCenter = cameraCenter; + state.requestedRadius = radius; + if (state.subscribed && !state.terminalFailure) { + send(new C2SRemoteUpdatePacket(state.id, state.requestedCenter, radius)); + state.lastSendTick = clientTicks; + } + } + + private static void fail(RemoteSceneHandle state, S2CRemoteUnavailablePacket.Reason reason, boolean terminal) { + LOGGER.warn("[GLASS projector] client unavailable id={} reason={} terminal={} pending={} pendingBytes={} globalPending={} globalPendingBytes={} lastSequence={} grantedCenter={} grantedRadius={}", + state.id, reason, terminal, state.pendingCount, state.pendingBytes, PENDING.size(), pendingBytes, + state.highestSequence, state.grantedCenter, state.grantedRadius); + if (terminal && state.subscribed) { + send(new C2SRemoteUnsubscribePacket(state.id)); + } + removePending(state); + state.grantedCenter = null; + state.grantedRadius = 0; + state.unavailable = reason; + state.terminalFailure = terminal; + if (terminal) { + state.subscribed = false; + } + if (state.scene != null) { + try { + state.scene.releaseSubscription(state.id); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to release remote scene subscription {}", state.id, exception); + } + } + } + + private static void failScene(RemoteClientScene scene, S2CRemoteUnavailablePacket.Reason reason) { + List affected = SUBSCRIPTIONS.values().stream() + .filter(state -> state.scene == scene) + .toList(); + for (RemoteSceneHandle state : affected) { + fail(state, reason, true); + } + } + + private static void detachState(RemoteSceneHandle state) { + if (state.scene != null) { + detachStateScene(state); + } + state.renderers.clear(); + } + + private static void detachStateScene(RemoteSceneHandle state) { + RemoteClientScene scene = state.scene; + state.scene = null; + try { + scene.releaseSubscription(state.id); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to release detached remote scene subscription {}", state.id, exception); + } + for (LevelRenderer renderer : state.renderers.keySet()) { + scene.detachRenderer(state.id, renderer); + } + if (SUBSCRIPTIONS.values().stream().noneMatch(candidate -> candidate.scene == scene)) { + SCENES.remove(scene.source().dimension(), scene); + try { + scene.close(); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to close remote scene {}", scene.source().key(), exception); + } + } + } + + private static void removePending(RemoteSceneHandle state) { + Iterator iterator = PENDING.iterator(); + while (iterator.hasNext()) { + PendingApply pending = iterator.next(); + if (pending.state != state) { + continue; + } + iterator.remove(); + accountDequeued(state, pending.command.bytes()); + } + } + + private static void accountDequeued(RemoteSceneHandle state, long bytes) { + state.pendingCount = Math.max(0, state.pendingCount - 1); + state.pendingBytes = Math.max(0L, state.pendingBytes - bytes); + pendingBytes = Math.max(0L, pendingBytes - bytes); + } + + private static void clear(boolean notifyServer) { + if (notifyServer) { + for (RemoteSceneHandle state : SUBSCRIPTIONS.values()) { + if (state.subscribed) { + send(new C2SRemoteUnsubscribePacket(state.id)); + } + } + } + PENDING.clear(); + ACKNOWLEDGEMENTS.clear(); + pendingBytes = 0L; + for (RemoteClientScene scene : new ArrayList<>(SCENES.values())) { + try { + scene.close(); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to close remote scene {}", scene.source().key(), exception); + } + } + SCENES.clear(); + SUBSCRIPTIONS.clear(); + } + + private static boolean send(CustomPacketPayload packet) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.getConnection() == null) { + return false; + } + try { + if (!ClientNetworking.canSend(packet.type())) { + return false; + } + ClientNetworking.send(packet); + return true; + } catch (IllegalStateException exception) { + return false; + } + } + + private static long lightBytes(ClientboundLightUpdatePacketData data) { + return 128L + (long) (data.getSkyYMask().cardinality() + data.getBlockYMask().cardinality()) * 2048; + } + + private record PendingApply(RemoteSceneHandle state, SceneUpdate command) { + } + + private sealed interface SceneUpdate permits ChunkControl, BlockUpdate, LightUpdate, WorldStateUpdate, EntityUpdates, BlockEntityUpdates { + long sequence(); + + long bytes(); + + void apply(RemoteSceneHandle state); + } + + private sealed interface ChunkControl extends SceneUpdate permits ChunkUpdate, UnloadUpdate { + ChunkPos chunk(); + + boolean loads(); + } + + private record ChunkUpdate(long sequence, ChunkPos chunk, ClientboundLevelChunkWithLightPacket data, int encodedBytes) implements ChunkControl { + @Override + public long bytes() { + return 64L + encodedBytes; + } + + @Override + public boolean loads() { + return true; + } + + @Override + public void apply(RemoteSceneHandle state) { + if (state.controlCurrent(chunk, sequence, true) && state.withinGrant(chunk, 1)) { + state.scene.applyChunk(state.id, data); + ACKNOWLEDGEMENTS.add(new C2SRemoteChunkAckPacket(state.id, sequence, chunk)); + } + } + } + + private record UnloadUpdate(long sequence, ChunkPos chunk) implements ChunkControl { + @Override + public long bytes() { + return 32L; + } + + @Override + public boolean loads() { + return false; + } + + @Override + public void apply(RemoteSceneHandle state) { + if (state.controlCurrent(chunk, sequence, false) && !state.withinGrant(chunk, 1)) { + state.scene.unloadChunk(state.id, chunk); + } + } + } + + private record BlockUpdate( + long sequence, + int sectionX, + int sectionY, + int sectionZ, + ClientboundSectionBlocksUpdatePacket updates + ) implements SceneUpdate { + @Override + public long bytes() { + return 64L + S2CRemoteBlockUpdatesPacket.MAX_UPDATES * 16L; + } + + @Override + public void apply(RemoteSceneHandle state) { + ChunkPos chunk = new ChunkPos(sectionX, sectionZ); + if (state.updateCurrent(chunk, sequence)) { + state.scene.applyBlockUpdates(state.id, sectionX, sectionY, sectionZ, updates); + } + } + } + + private record LightUpdate(long sequence, ChunkPos chunk, ClientboundLightUpdatePacketData light) implements SceneUpdate { + @Override + public long bytes() { + return lightBytes(light); + } + + @Override + public void apply(RemoteSceneHandle state) { + if (state.updateCurrent(chunk, sequence)) { + state.scene.applyLight(state.id, chunk, light); + } + } + } + + private record WorldStateUpdate(long sequence, RemoteWorldState worldState) implements SceneUpdate { + @Override + public long bytes() { + return 64L; + } + + @Override + public void apply(RemoteSceneHandle state) { + state.scene.applyWorldState(worldState); + } + } + + private record EntityUpdates(long sequence, List messages) implements SceneUpdate { + @Override + public long bytes() { + return 64L + messages.size() * 1024L; + } + + @Override + public void apply(RemoteSceneHandle state) { + state.scene.applyEntities(state.id, messages); + } + } + + private record BlockEntityUpdates(long sequence, List updates) implements SceneUpdate { + @Override + public long bytes() { + return 64L + updates.size() * 4096L; + } + + @Override + public void apply(RemoteSceneHandle state) { + state.scene.applyBlockEntities(state.id, updates.stream() + .filter(update -> state.updateCurrent(ChunkPos.containing(update.getPos()), sequence)) + .toList()); + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/RemoteSceneEntities.java b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneEntities.java new file mode 100644 index 0000000..451d271 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneEntities.java @@ -0,0 +1,249 @@ +package dev.imb11.client.remote; + +import com.mojang.authlib.GameProfile; +import dev.imb11.sync.remote.RemoteSubscriptionId; +import dev.imb11.sync.remote.S2CRemoteEntitiesPacket; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.client.player.RemotePlayer; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.*; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.PositionMoveRotation; +import net.minecraft.world.entity.Leashable; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; + +final class RemoteSceneEntities { + private final ClientLevel level; + private final Set vanillaEntities = new HashSet<>(); + private int applying; + private final Map> owners = new LinkedHashMap<>(); + private final Map passengers = new LinkedHashMap<>(); + + RemoteSceneEntities(ClientLevel level) { + this.level = level; + } + + void apply(RemoteSubscriptionId subscription, List messages) { + applying++; + try { + for (S2CRemoteEntitiesPacket.EntityMessage message : messages) { + int id = message.entityId(); + Packet packet = message.packet(); + if (packet instanceof ClientboundRemoveEntitiesPacket removal) { + for (int removed : removal.getEntityIds()) { + release(subscription, removed); + } + continue; + } + if (packet instanceof ClientboundAddEntityPacket + && !owners.containsKey(id) && level == Minecraft.getInstance().level && level.getEntity(id) != null) { + vanillaEntities.add(id); + } + if (packet instanceof ClientboundAddEntityPacket spawn) { + if (owners.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(subscription) && level.getEntity(id) == null) { + Entity entity; + if (spawn.getType() == EntityType.PLAYER) { + PlayerInfo info = Minecraft.getInstance().getConnection().getPlayerInfo(spawn.getUUID()); + entity = new RemotePlayer(level, info == null ? new GameProfile(spawn.getUUID(), "Player") : info.getProfile()); + } else { + entity = spawn.getType().create(level, EntitySpawnReason.LOAD); + } + if (entity != null) { + entity.recreateFromPacket(spawn); + entity.setOldPosAndRot(); + level.addEntity(entity); + } + } + continue; + } + LinkedHashSet subscriptions = owners.get(id); + Entity entity = level.getEntity(id); + if (entity == null || vanillaEntities.contains(id) || subscriptions == null || !subscriptions.contains(subscription)) { + continue; + } + if ((packet instanceof ClientboundAnimatePacket || packet instanceof ClientboundHurtAnimationPacket + || packet instanceof ClientboundEntityEventPacket) && !subscriptions.getFirst().equals(subscription)) { + continue; + } + if (packet instanceof ClientboundSetEntityDataPacket data) { + entity.getEntityData().assignValues(data.packedItems()); + } else if (packet instanceof ClientboundMoveEntityPacket movement) { + if (movement.hasPosition()) { + Vec3 position = entity.getPositionCodec().decode(movement.getXa(), movement.getYa(), movement.getZa()); + entity.getPositionCodec().setBase(position); + if (movement.hasRotation()) { + entity.moveOrInterpolateTo(position, movement.getYRot(), movement.getXRot()); + } else { + entity.moveOrInterpolateTo(position); + } + } else if (movement.hasRotation()) { + entity.moveOrInterpolateTo(movement.getYRot(), movement.getXRot()); + } + entity.setOnGround(movement.isOnGround()); + } else if (packet instanceof ClientboundEntityPositionSyncPacket sync) { + entity.getPositionCodec().setBase(sync.values().position()); + applyPosition(entity, sync.values()); + entity.setOnGround(sync.onGround()); + } else if (packet instanceof ClientboundTeleportEntityPacket teleport) { + applyPosition(entity, PositionMoveRotation.calculateAbsolute(PositionMoveRotation.of(entity), teleport.change(), teleport.relatives())); + entity.setOnGround(teleport.onGround()); + } else if (packet instanceof ClientboundRotateHeadPacket head) { + entity.lerpHeadTo(head.getYHeadRot(), 3); + } else if (packet instanceof ClientboundSetEntityMotionPacket motion) { + entity.lerpMotion(motion.movement()); + } else if (packet instanceof ClientboundSetEquipmentPacket equipment && entity instanceof LivingEntity living) { + equipment.getSlots().forEach(slot -> living.setItemSlot(slot.getFirst(), slot.getSecond())); + } else if (packet instanceof ClientboundUpdateAttributesPacket attributes && entity instanceof LivingEntity living) { + for (ClientboundUpdateAttributesPacket.AttributeSnapshot snapshot : attributes.getValues()) { + var attribute = living.getAttributes().getInstance(snapshot.attribute()); + if (attribute != null) { + attribute.setBaseValue(snapshot.base()); + attribute.removeModifiers(); + snapshot.modifiers().forEach(attribute::addTransientModifier); + } + } + } else if (packet instanceof ClientboundSetPassengersPacket riding) { + passengers.put(riding.getVehicle(), riding.getPassengers()); + } else if (packet instanceof ClientboundSetEntityLinkPacket leash && entity instanceof Leashable leashable) { + leashable.setDelayedLeashHolderId(leash.getDestId()); + } else if (packet instanceof ClientboundAnimatePacket animation) { + if (entity instanceof LivingEntity living && (animation.getAction() == 0 || animation.getAction() == 3)) { + living.swing(animation.getAction() == 0 ? InteractionHand.MAIN_HAND : InteractionHand.OFF_HAND); + } else if (entity instanceof Player player && animation.getAction() == 2) { + player.stopSleepInBed(false, false); + } + } else if (packet instanceof ClientboundHurtAnimationPacket hurt) { + entity.animateHurt(hurt.yaw()); + } else if (packet instanceof ClientboundEntityEventPacket event) { + entity.handleEntityEvent(event.getEventId()); + } else if (packet instanceof ClientboundUpdateMobEffectPacket effect && entity instanceof LivingEntity living) { + MobEffectInstance instance = new MobEffectInstance(effect.getEffect(), effect.getEffectDurationTicks(), + effect.getEffectAmplifier(), effect.isEffectAmbient(), effect.isEffectVisible(), effect.effectShowsIcon()); + if (!effect.shouldBlend()) { + instance.skipBlending(); + } + living.forceAddEffect(instance, null); + } else if (packet instanceof ClientboundRemoveMobEffectPacket effect && entity instanceof LivingEntity living) { + living.removeEffect(effect.effect()); + } + } + resolvePassengers(); + } finally { + applying--; + } + } + + private static void applyPosition(Entity entity, PositionMoveRotation values) { + if (entity.position().distanceToSqr(values.position()) > 4096.0) { + entity.snapTo(values.position(), values.yRot(), values.xRot()); + } else { + entity.moveOrInterpolateTo(values.position(), values.yRot(), values.xRot()); + } + entity.setDeltaMovement(values.deltaMovement()); + } + + void onVanillaAdded(Entity entity) { + if (applying > 0 || !owners.containsKey(entity.getId())) { + return; + } + vanillaEntities.add(entity.getId()); + Entity existing = level.getEntity(entity.getId()); + if (existing != null && existing != entity) { + removeEntity(entity.getId()); + } + } + + boolean onVanillaRemoved(int id) { + if (applying > 0 || !owners.containsKey(id)) { + return false; + } + vanillaEntities.remove(id); + return true; + } + + private void removeEntity(int id) { + applying++; + try { + level.removeEntity(id, Entity.RemovalReason.UNLOADED_TO_CHUNK); + } finally { + applying--; + } + } + + private void resolvePassengers() { + passengers.forEach((id, ids) -> { + Entity vehicle = level.getEntity(id); + if (vehicle == null || vanillaEntities.contains(id)) { + return; + } + List expected = new ArrayList<>(); + for (int passengerId : ids) { + Entity passenger = level.getEntity(passengerId); + if (passenger != null) { + expected.add(passenger); + } + } + if (!vehicle.getPassengers().equals(expected)) { + vehicle.ejectPassengers(); + expected.forEach(passenger -> passenger.startRiding(vehicle, true, false)); + } + }); + } + + void tick() { + if (level == Minecraft.getInstance().level) { + return; + } + for (int id : List.copyOf(owners.keySet())) { + Entity entity = level.getEntity(id); + if (entity != null && !entity.isRemoved() && !entity.isPassenger()) { + level.tickNonPassenger(entity); + } + } + ((RemoteSceneLevel) level).tickVisualBlockEntities(); + } + + void release(RemoteSubscriptionId subscription) { + for (int id : List.copyOf(owners.keySet())) { + release(subscription, id); + } + } + + private void release(RemoteSubscriptionId subscription, int id) { + LinkedHashSet subscriptions = owners.get(id); + if (subscriptions != null && subscriptions.remove(subscription) && subscriptions.isEmpty()) { + owners.remove(id); + passengers.remove(id); + if (!vanillaEntities.remove(id)) { + removeEntity(id); + } + } + } + + void clear() { + for (int id : owners.keySet()) { + if (!vanillaEntities.contains(id)) { + removeEntity(id); + } + } + vanillaEntities.clear(); + owners.clear(); + passengers.clear(); + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/RemoteSceneHandle.java b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneHandle.java new file mode 100644 index 0000000..b324a67 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneHandle.java @@ -0,0 +1,113 @@ +package dev.imb11.client.remote; + +import dev.imb11.debug.LogThrottle; +import dev.imb11.sync.remote.RemoteSubscriptionId; +import dev.imb11.sync.remote.S2CRemoteUnavailablePacket; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import dev.imb11.client.renderer.projection.ProjectionLightmap; +import net.minecraft.world.level.ChunkPos; +import org.jetbrains.annotations.Nullable; + +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public final class RemoteSceneHandle { + final RemoteSubscriptionId id; + final IdentityHashMap renderers = new IdentityHashMap<>(); + final LogThrottle grantLog = new LogThrottle(TimeUnit.SECONDS.toNanos(5L)); + private final Map chunkControls = new LinkedHashMap<>(); + ChunkPos requestedCenter; + int requestedRadius; + ChunkPos grantedCenter; + int grantedRadius; + RemoteClientScene scene; + S2CRemoteUnavailablePacket.Reason unavailable; + long highestSequence = -1L; + long lastSendTick; + long stalePackets; + long discardedApplies; + int pendingCount; + long pendingBytes; + boolean subscribed; + boolean terminalFailure; + + RemoteSceneHandle(RemoteSubscriptionId id, ChunkPos requestedCenter, int requestedRadius) { + this.id = id; + this.requestedCenter = requestedCenter; + this.requestedRadius = requestedRadius; + } + + public RemoteSubscriptionId subscription() { + return id; + } + + public @Nullable ClientLevel level() { + return isActive() && scene != null ? scene.level() : null; + } + + public @Nullable ProjectionLightmap lightTexture() { + return isActive() && scene != null ? scene.lightTexture() : null; + } + + public int grantedRadius() { + return isActive() ? grantedRadius : 0; + } + + public boolean isReady(ChunkPos cameraCenter) { + return isActive() + && scene != null + && unavailable == null + && withinGrant(cameraCenter, 0) + && scene.isReady(cameraCenter); + } + + public boolean isComplete() { + return isActive() + && scene != null + && unavailable == null + && grantedCenter != null + && scene.isReady(grantedCenter, grantedRadius + 1); + } + + public boolean isUnavailable() { + return !isActive() || unavailable != null; + } + + public boolean isTerminalFailure() { + return !isActive() || terminalFailure; + } + + public Object diagnostics() { + return RemoteSceneClientManager.diagnostics(this); + } + + boolean isActive() { + return RemoteSceneClientManager.isActive(this); + } + + boolean withinGrant(ChunkPos pos, int padding) { + return grantedCenter != null + && Math.abs(pos.x() - grantedCenter.x()) <= grantedRadius + padding + && Math.abs(pos.z() - grantedCenter.z()) <= grantedRadius + padding; + } + + void recordControl(ChunkPos chunk, long sequence, boolean load) { + chunkControls.put(chunk.pack(), new Control(sequence, load)); + } + + boolean controlCurrent(ChunkPos chunk, long sequence, boolean load) { + Control control = chunkControls.get(chunk.pack()); + return control != null && control.sequence == sequence && control.load == load; + } + + boolean updateCurrent(ChunkPos chunk, long sequence) { + Control control = chunkControls.get(chunk.pack()); + return control == null || sequence >= control.sequence; + } + + private record Control(long sequence, boolean load) { + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/RemoteSceneLevel.java b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneLevel.java new file mode 100644 index 0000000..0c9990f --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneLevel.java @@ -0,0 +1,52 @@ +package dev.imb11.client.remote; + +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.core.Holder; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.resources.ResourceKey; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.dimension.DimensionType; + +import java.util.function.Supplier; + +final class RemoteSceneLevel extends ClientLevel { + RemoteSceneLevel(ClientPacketListener connection, ClientLevelData data, ResourceKey dimension, + Holder type, int viewDistance, + LevelRenderer renderer, boolean debug, long seed, int seaLevel) { + super(connection, data, dimension, type, viewDistance, 0, renderer, debug, seed, seaLevel); + } + + void tickVisualBlockEntities() { + tickBlockEntities(); + } + + @Override + public void addParticle(ParticleOptions particle, double x, double y, double z, double dx, double dy, double dz) { + } + + @Override + public void addParticle(ParticleOptions particle, boolean force, boolean alwaysShow, double x, double y, double z, double dx, double dy, double dz) { + } + + @Override + public void addAlwaysVisibleParticle(ParticleOptions particle, double x, double y, double z, double dx, double dy, double dz) { + } + + @Override + public void addAlwaysVisibleParticle(ParticleOptions particle, boolean force, double x, double y, double z, double dx, double dy, double dz) { + } + + @Override + public void playLocalSound(double x, double y, double z, SoundEvent sound, SoundSource source, float volume, float pitch, boolean delay) { + } + + @Override + public void playLocalSound(Entity entity, SoundEvent sound, SoundSource source, float volume, float pitch) { + } +} diff --git a/glass/src/main/java/dev/imb11/client/remote/RemoteSceneSinkRenderer.java b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneSinkRenderer.java new file mode 100644 index 0000000..63b2aa5 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/remote/RemoteSceneSinkRenderer.java @@ -0,0 +1,68 @@ +package dev.imb11.client.remote; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.RenderBuffers; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.state.BlockState; + +final class RemoteSceneSinkRenderer extends LevelRenderer { + private RemoteClientScene scene; + + RemoteSceneSinkRenderer(Minecraft minecraft, RenderBuffers renderBuffers) { + super( + minecraft, + minecraft.getEntityRenderDispatcher(), + minecraft.getBlockEntityRenderDispatcher(), + renderBuffers, minecraft.gameRenderer.getGameRenderState(), minecraft.gameRenderer.getFeatureRenderDispatcher() + ); + } + + void bind(RemoteClientScene scene) { + this.scene = scene; + } + + @Override + public void blockChanged(BlockGetter level, BlockPos pos, BlockState oldState, BlockState newState, int flags) { + if (scene != null) { + scene.dirtyBlock(pos, oldState, newState); + } + } + + @Override + public void setBlocksDirty(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + if (scene != null) { + scene.dirtyBlocks(minX, minY, minZ, maxX, maxY, maxZ); + } + } + + @Override + public void setBlockDirty(BlockPos pos, BlockState oldState, BlockState newState) { + if (scene != null) { + scene.dirtyBlock(pos, oldState, newState); + } + } + + @Override + public void setSectionDirtyWithNeighbors(int sectionX, int sectionY, int sectionZ) { + if (scene != null) { + scene.dirtySectionWithNeighbors(sectionX, sectionY, sectionZ); + } + } + + @Override + public void setSectionDirty(int sectionX, int sectionY, int sectionZ) { + if (scene != null) { + scene.dirtySection(sectionX, sectionY, sectionZ); + } + } + + @Override + public void onChunkReadyToRender(ChunkPos pos) { + if (scene != null) { + scene.chunkLoaded(pos); + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/block/ProjectorBlockEntityRenderer.java b/glass/src/main/java/dev/imb11/client/renderer/block/ProjectorBlockEntityRenderer.java new file mode 100644 index 0000000..f27e3d2 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/block/ProjectorBlockEntityRenderer.java @@ -0,0 +1,316 @@ +package dev.imb11.client.renderer.block; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.math.Axis; +import dev.imb11.blocks.ProjectorBlock; +import dev.imb11.debug.ProjectorActivationTrace; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.client.ClientProjectionSourceRegistry; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import dev.imb11.client.renderer.projection.ProjectionSurfaceRenderer; +import dev.imb11.client.renderer.projection.ProjectionStage; +import dev.imb11.projection.ProjectionSurface; +import dev.imb11.sync.ProjectionSource; +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +public final class ProjectorBlockEntityRenderer { + private static final com.mojang.blaze3d.pipeline.RenderPipeline LENS_PIPELINE = com.mojang.blaze3d.pipeline.RenderPipeline.builder() + .withLocation(Identifier.fromNamespaceAndPath("glass", "projector_lens")) + .withVertexShader(Identifier.fromNamespaceAndPath("glass", "core/projector_lens")) + .withFragmentShader(Identifier.fromNamespaceAndPath("glass", "core/projector_lens")) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX, VertexFormat.Mode.QUADS) + .withUniform("DynamicTransforms", com.mojang.blaze3d.shaders.UniformType.UNIFORM_BUFFER) + .withUniform("Projection", com.mojang.blaze3d.shaders.UniformType.UNIFORM_BUFFER) + .withUniform("Globals", com.mojang.blaze3d.shaders.UniformType.UNIFORM_BUFFER) + .withSampler("Sampler0") + .withDepthStencilState(new com.mojang.blaze3d.pipeline.DepthStencilState(com.mojang.blaze3d.platform.CompareOp.LESS_THAN_OR_EQUAL, false)) + .withColorTargetState(new com.mojang.blaze3d.pipeline.ColorTargetState(com.mojang.blaze3d.pipeline.BlendFunction.TRANSLUCENT)) + .withCull(false).build(); + private static final long LOADING_FADE_NANOS = 500_000_000L; + private static final float LOADING_MAX_OPACITY = 0.35F; + private static final int VIEW_DISTANCE = 64; + private static final double VIEW_DISTANCE_SQUARED = VIEW_DISTANCE * VIEW_DISTANCE; + private static final Map> LOADED_PROJECTORS = new IdentityHashMap<>(); + private static ClientLevel frustumLevel; + private static long frustumFrame = Long.MIN_VALUE; + private static Frustum frustum; + + private ProjectorBlockEntityRenderer() { + } + + public static void renderAll(Matrix4f modelView) { + Minecraft minecraft = Minecraft.getInstance(); + ClientLevel level = minecraft.level; + var projectors = LOADED_PROJECTORS.get(level); + if (projectors == null) { + return; + } + Vec3 camera = minecraft.gameRenderer.getMainCamera().position(); + for (ProjectorBlockEntity projector : List.copyOf(projectors.values())) { + if (projector.isRemoved() || distanceToSqr(renderBounds(projector, projector.getProjectionSurface()), camera) > VIEW_DISTANCE_SQUARED) { + continue; + } + PoseStack poses = new PoseStack(); + poses.mulPose(modelView); + BlockPos pos = projector.getBlockPos(); + poses.translate(pos.getX() - camera.x, pos.getY() - camera.y, pos.getZ() - camera.z); + render(projector, poses); + } + } + + public static void registerLoaded(ClientLevel level, ProjectorBlockEntity entity) { + Map projectors = LOADED_PROJECTORS.computeIfAbsent( + level, + ignored -> new HashMap<>() + ); + ProjectorBlockEntity previous = projectors.put(entity.getBlockPos().immutable(), entity); + if (previous != null && previous != entity) { + ProjectorActivationTrace.forget(previous); + } + } + + public static void unregisterLoaded(ClientLevel level, ProjectorBlockEntity entity) { + ProjectorActivationTrace.forget(entity); + Map projectors = LOADED_PROJECTORS.get(level); + if (projectors != null) { + projectors.remove(entity.getBlockPos(), entity); + if (projectors.isEmpty()) { + LOADED_PROJECTORS.remove(level); + } + } + } + + public static void onMainRendererRebuilt(LevelRenderer renderer) { + clearFrustum(); + } + + public static void clearLoaded() { + LOADED_PROJECTORS.clear(); + ProjectorActivationTrace.clear(); + } + + public static void prepareNearby(Minecraft minecraft) { + ClientLevel level = minecraft.level; + Map projectors = LOADED_PROJECTORS.get(level); + if (projectors == null || minecraft.player == null) { + return; + } + Vec3 viewer = minecraft.gameRenderer.getMainCamera().position(); + List nearby = new ArrayList<>(); + for (ProjectorBlockEntity projector : projectors.values()) { + projector.setClientProjectionReady(false); + if (!projector.isActive() || projector.isRemoved()) { + projector.stopLoadingAnimation(); + } + if (projector.isRemoved()) { + continue; + } + if (ClientProjectionSourceRegistry.resolve(projector.getChannel()) == null) { + ProjectorActivationTrace.report(projector, ProjectionStage.MISSING_CHANNEL_SOURCE, null); + continue; + } + if (distanceToSqr(new AABB(projector.getBlockPos()).inflate(ProjectionSurface.MAX_RADIUS), viewer) + > VIEW_DISTANCE_SQUARED) { + ProjectorActivationTrace.report(projector, ProjectionStage.OUTSIDE_PRELOAD_RANGE, null); + continue; + } + projector.prepareProjectionSurface(); + ProjectionSurface surface = projector.getProjectionSurface(); + if (surface != null && distanceToSqr(surface.renderBounds(), viewer) <= VIEW_DISTANCE_SQUARED) { + nearby.add(projector); + } else { + ProjectorActivationTrace.report(projector, surface == null ? ProjectionStage.MISSING_SURFACE : ProjectionStage.OUTSIDE_SURFACE_RANGE, null); + } + } + nearby.sort(Comparator.comparing((ProjectorBlockEntity projector) -> !projector.isProjectionVisible()) + .thenComparingDouble(projector -> distanceToSqr(projector.getProjectionSurface().renderBounds(), viewer)) + .thenComparingLong(projector -> projector.getBlockPos().asLong())); + int selectedCount = Math.min(nearby.size(), RemoteSceneServerManager.MAX_SUBSCRIPTIONS_PER_PLAYER); + ProjectionRenderManager.prepareRequests(nearby.subList(0, selectedCount)); + for (int i = 0; i < selectedCount; i++) { + ProjectorBlockEntity projector = nearby.get(i); + ProjectionRenderManager.ProjectionFeed feed = ProjectionRenderManager.requestFeed( + ClientProjectionSourceRegistry.resolve(projector.getChannel()), + projector.getBlockPos(), + projector.getProjectionSurface() + ); + projector.setClientProjectionReady(ProjectionRenderManager.isReady(feed)); + ProjectorActivationTrace.report(projector, feed == null ? ProjectionStage.FEED_REQUEST_REJECTED : feed.stage(), feed); + } + for (int i = RemoteSceneServerManager.MAX_SUBSCRIPTIONS_PER_PLAYER; i < nearby.size(); i++) { + ProjectorActivationTrace.report(nearby.get(i), ProjectionStage.SUBSCRIPTION_LIMIT, null); + } + ProjectionRenderManager.alignFeeds(nearby.subList(0, selectedCount)); + } + + public static void prepareSurfaceBlending(ClientLevel level) { + List projections = new ArrayList<>(); + Map projectors = LOADED_PROJECTORS.get(level); + if (projectors != null) { + for (ProjectorBlockEntity projector : projectors.values()) { + ProjectionSurface surface = projector.getProjectionSurface(); + if (projector.isRemoved() || !projector.isProjectionVisible() || surface == null) { + continue; + } + ProjectionRenderManager.ProjectionFeed feed = ProjectionRenderManager.preparedFeed( + ClientProjectionSourceRegistry.resolve(projector.getChannel()), + projector.getBlockPos(), + surface + ); + if (ProjectionRenderManager.isReady(feed)) { + projections.add(new ProjectionSurfaceRenderer.Projection( + projector.getBlockPos(), surface, feed, projector.getRevealDistance() + )); + } + } + } + ProjectionSurfaceRenderer.prepare(level, projections); + } + + public static void releaseLevel(ClientLevel level) { + ProjectorActivationTrace.forgetLevel(level); + LOADED_PROJECTORS.remove(level); + if (frustumLevel == level) { + clearFrustum(); + } + } + + public static void reset() { + ProjectorActivationTrace.clear(); + clearFrustum(); + } + + private static void render(ProjectorBlockEntity entity, PoseStack matrices) { + if (!(entity.getLevel() instanceof ClientLevel clientLevel)) { + return; + } + + ProjectionSurface surface = entity.getProjectionSurface(); + AABB renderBounds = renderBounds(entity, surface); + long frame = ProjectionRenderManager.currentFrameSequence(); + if (!entity.claimRenderFrame(frame)) { + return; + } + if (!currentFrustum(clientLevel, frame).isVisible(renderBounds)) { + return; + } + + renderReadyGlint(entity, matrices); + + if (!entity.isProjectionVisible() || surface == null) { + entity.stopLoadingAnimation(); + return; + } + + ProjectionSource source = ClientProjectionSourceRegistry.resolve(entity.getChannel()); + ProjectionRenderManager.ProjectionFeed feed = ProjectionRenderManager.visibleFeed( + source, + entity.getBlockPos(), + surface + ); + boolean loading = entity.isActive() && source != null && !ProjectionRenderManager.isReady(feed); + ProjectionSurfaceRenderer.render( + clientLevel, + entity.getBlockPos(), + surface, + feed, + matrices, + loadingOpacity(entity, loading) + ); + } + + private static void renderReadyGlint(ProjectorBlockEntity entity, PoseStack matrices) { + matrices.pushPose(); + matrices.translate(0.5D, 0.5D, 0.5D); + Direction facing = entity.getBlockState().getValue(ProjectorBlock.FACING); + if (facing == Direction.DOWN) { + matrices.mulPose(Axis.XP.rotationDegrees(-180.0F)); + } else if (facing.getAxis().isHorizontal()) { + matrices.mulPose(Axis.YP.rotationDegrees(-facing.get2DDataValue() * 90.0F)); + matrices.mulPose(Axis.XP.rotationDegrees(-90.0F)); + } + matrices.translate(-0.5D, -0.5D, -0.5D); + var vertices = com.mojang.blaze3d.vertex.Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX); + Matrix4f pose = matrices.last().pose(); + float min = 6.0F / 16.0F; + float max = 10.0F / 16.0F; + float y = -0.001F; + vertices.addVertex(pose, min, y, min).setUv(0.0F, 0.0F); + vertices.addVertex(pose, max, y, min).setUv(1.0F, 0.0F); + vertices.addVertex(pose, max, y, max).setUv(1.0F, 1.0F); + vertices.addVertex(pose, min, y, max).setUv(0.0F, 1.0F); + var texture = Minecraft.getInstance().getTextureManager().getTexture(Identifier.withDefaultNamespace("textures/misc/enchanted_glint_item.png")).getTextureView(); + try (var mesh = new dev.imb11.client.renderer.projection.ProjectionMesh(vertices.buildOrThrow())) { + mesh.draw(LENS_PIPELINE, new Matrix4f(), Map.of("Sampler0", texture), null); + } + matrices.popPose(); + } + + private static float loadingOpacity(ProjectorBlockEntity entity, boolean loading) { + if (!loading) { + entity.stopLoadingAnimation(); + return -1.0F; + } + long now = System.nanoTime(); + long elapsed = (now - entity.loadingAnimationStart(now)) % (LOADING_FADE_NANOS * 2L); + double phase = (double) elapsed / LOADING_FADE_NANOS; + return LOADING_MAX_OPACITY * (float) (0.5D - 0.5D * Math.cos(Math.PI * phase)); + } + + private static AABB renderBounds(ProjectorBlockEntity entity, ProjectionSurface surface) { + AABB blockBounds = new AABB(entity.getBlockPos()); + return surface == null || !entity.isProjectionVisible() + ? blockBounds + : blockBounds.minmax(surface.renderBounds()); + } + + private static double distanceToSqr(AABB bounds, Vec3 position) { + double x = Math.max(Math.max(bounds.minX - position.x, 0.0D), position.x - bounds.maxX); + double y = Math.max(Math.max(bounds.minY - position.y, 0.0D), position.y - bounds.maxY); + double z = Math.max(Math.max(bounds.minZ - position.z, 0.0D), position.z - bounds.maxZ); + return x * x + y * y + z * z; + } + + private static Frustum currentFrustum(ClientLevel level, long frame) { + if (frustum == null || frustumLevel != level || frustumFrame != frame) { + Minecraft minecraft = Minecraft.getInstance(); + Vec3 cameraPosition = minecraft.gameRenderer.getMainCamera().position(); + frustum = new Frustum( + minecraft.gameRenderer.getGameRenderState().levelRenderState.cameraRenderState.viewRotationMatrix, + minecraft.gameRenderer.getGameRenderState().levelRenderState.cameraRenderState.projectionMatrix + ); + frustum.prepare(cameraPosition.x, cameraPosition.y, cameraPosition.z); + frustumLevel = level; + frustumFrame = frame; + } + return frustum; + } + + private static void clearFrustum() { + frustumLevel = null; + frustumFrame = Long.MIN_VALUE; + frustum = null; + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/block/TerminalBlockEntityRenderer.java b/glass/src/main/java/dev/imb11/client/renderer/block/TerminalBlockEntityRenderer.java new file mode 100644 index 0000000..06afab4 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/block/TerminalBlockEntityRenderer.java @@ -0,0 +1,132 @@ +package dev.imb11.client.renderer.block; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.QuadInstance; +import com.mojang.math.Axis; +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.TerminalBlock; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.block.dispatch.BlockStateModel; +import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.List; + +public final class TerminalBlockEntityRenderer implements BlockEntityRenderer { + private BlockStateModel bakedModel; + private List frame = List.of(); + private List eye = List.of(); + private final Vector3f eyeCenter = new Vector3f(); + + public TerminalBlockEntityRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public void extractRenderState(TerminalBlockEntity entity, State state, float partialTick, Vec3 camera, + ModelFeatureRenderer.CrumblingOverlay breaking) { + BlockEntityRenderer.super.extractRenderState(entity, state, partialTick, camera, breaking); + BlockStateModel model = Minecraft.getInstance().getModelManager().getBlockStateModelSet().get( + GBlocks.TERMINAL.defaultBlockState().setValue(TerminalBlock.FACING, Direction.SOUTH)); + if (model != bakedModel) { + rebuild(model); + } + state.frame = frame; + state.eye = eye; + state.eyeCenter.set(eyeCenter); + state.rotation.set(entity.getEyeRotation(partialTick)); + state.facing = entity.getBlockState().getValue(TerminalBlock.FACING); + } + + @Override + public void submit(State state, PoseStack matrices, SubmitNodeCollector collector, CameraRenderState camera) { + matrices.pushPose(); + matrices.translate(0.5D, 0.5D, 0.5D); + switch (state.facing) { + case NORTH -> matrices.mulPose(Axis.YP.rotationDegrees(-180.0F)); + case WEST -> matrices.mulPose(Axis.YP.rotationDegrees(-90.0F)); + case EAST -> matrices.mulPose(Axis.YP.rotationDegrees(-270.0F)); + case UP -> matrices.mulPose(Axis.XP.rotationDegrees(-90.0F)); + case DOWN -> { + matrices.mulPose(Axis.YP.rotationDegrees(-180.0F)); + matrices.mulPose(Axis.XP.rotationDegrees(-270.0F)); + } + default -> {} + } + matrices.translate(-0.5D, -0.5D, -0.5D); + submitQuads(state.frame, matrices, collector, state.lightCoords); + matrices.translate(state.eyeCenter.x(), state.eyeCenter.y(), state.eyeCenter.z()); + matrices.mulPose(state.rotation); + matrices.translate(-state.eyeCenter.x(), -state.eyeCenter.y(), -state.eyeCenter.z()); + submitQuads(state.eye, matrices, collector, state.lightCoords); + matrices.popPose(); + } + + private void rebuild(BlockStateModel model) { + List frame = new ArrayList<>(); + List eye = new ArrayList<>(); + List parts = new ArrayList<>(); + model.collectParts(RandomSource.create(42L), parts); + for (BlockStateModelPart part : parts) { + List quads = new ArrayList<>(part.getQuads(null)); + for (Direction direction : Direction.values()) { + quads.addAll(part.getQuads(direction)); + } + for (BakedQuad quad : quads) { + (quad.materialInfo().tintIndex() == 1 ? eye : frame).add(quad); + } + } + eyeCenter.zero(); + for (BakedQuad quad : eye) { + for (int vertex = 0; vertex < 4; vertex++) { + eyeCenter.add(quad.position(vertex)); + } + } + if (!eye.isEmpty()) { + eyeCenter.div(eye.size() * 4); + } + this.frame = List.copyOf(frame); + this.eye = List.copyOf(eye); + bakedModel = model; + } + + private static void submitQuads(List quads, PoseStack matrices, SubmitNodeCollector collector, int light) { + collector.submitCustomGeometry(matrices, RenderTypes.entityCutout(TextureAtlas.LOCATION_BLOCKS), (pose, vertices) -> { + QuadInstance instance = new QuadInstance(); + instance.setColor(-1); + instance.setLightCoords(light); + instance.setOverlayCoords(OverlayTexture.NO_OVERLAY); + for (BakedQuad quad : quads) { + vertices.putBakedQuad(pose, quad, instance); + } + }); + } + + public static final class State extends BlockEntityRenderState { + private List frame = List.of(); + private List eye = List.of(); + private final Vector3f eyeCenter = new Vector3f(); + private final Quaternionf rotation = new Quaternionf(); + private Direction facing = Direction.SOUTH; + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/PortalSide.java b/glass/src/main/java/dev/imb11/client/renderer/projection/PortalSide.java new file mode 100644 index 0000000..eacf7bf --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/PortalSide.java @@ -0,0 +1,180 @@ +package dev.imb11.client.renderer.projection; + +import dev.imb11.projection.ProjectionSurface; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.List; + +final class PortalSide { + private static final double SIDE_EPSILON = 1.0E-7D; + private static final double DISTANCE_EPSILON = SIDE_EPSILON * SIDE_EPSILON; + private static final double CROSSING_EPSILON = 1.0E-6D; + + private ProjectionView view; + private Vec3 lastViewerPosition; + private double side; + private long lastUpdateFrame = Long.MIN_VALUE; + + void track(ProjectionView view, Vec3 viewer, long frame) { + if (!view.equals(this.view)) { + boolean frameChanged = this.view != null && !this.view.sameFrame(view); + this.view = view; + lastViewerPosition = frameChanged || pointOnSurface(view, viewer) ? null : viewer; + lastUpdateFrame = Long.MIN_VALUE; + if (frameChanged) { + side = 0.0D; + } + } + update(viewer, frame); + } + + void update(Vec3 viewer, long frame) { + if (lastUpdateFrame == frame || view == null) { + return; + } + lastUpdateFrame = frame; + if (pointOnSurface(view, viewer)) { + if (side == 0.0D) { + lastViewerPosition = null; + } + return; + } + if (side == 0.0D) { + side = classify(view, viewer); + lastViewerPosition = viewer; + return; + } + Vec3 previous = lastViewerPosition; + lastViewerPosition = viewer; + if (previous == null || previous.distanceToSqr(viewer) <= DISTANCE_EPSILON) { + return; + } + if (segmentIntersectsBounds(view.bounds(), previous, viewer) + && (countSurfaceCrossings(view, previous, viewer) & 1) != 0) { + side = -side; + } + } + + void inheritSide(PortalSide other) { + side = other.side; + } + + double resolve(ProjectionView view, Vec3 viewer) { + return side == 0.0D ? classify(view, viewer) : side; + } + + private static double classify(ProjectionView view, Vec3 point) { + double closestDistance = Double.POSITIVE_INFINITY; + double closestSignedDistance = 0.0D; + for (ProjectionSurface.Face face : view.faces()) { + double distance = distanceToFaceSquared(face, point); + double signedDistance = signedFaceDistance(face, point); + if (distance < closestDistance - DISTANCE_EPSILON + || (Math.abs(distance - closestDistance) <= DISTANCE_EPSILON && signedDistance > closestSignedDistance)) { + closestDistance = distance; + closestSignedDistance = signedDistance; + } + } + if (closestDistance == Double.POSITIVE_INFINITY) { + closestSignedDistance = point.subtract(view.projectorAnchor()).dot(view.normal()); + } + return closestSignedDistance < 0.0D ? -1.0D : 1.0D; + } + + private static boolean pointOnSurface(ProjectionView view, Vec3 point) { + if (!view.bounds().contains(point) + || (!nearInteger(point.x) && !nearInteger(point.y) && !nearInteger(point.z))) { + return false; + } + for (ProjectionSurface.Face face : view.faces()) { + if (Math.abs(signedFaceDistance(face, point)) <= SIDE_EPSILON && pointWithinFace(face, point)) { + return true; + } + } + return false; + } + + private static int countSurfaceCrossings(ProjectionView view, Vec3 start, Vec3 end) { + Vec3 delta = end.subtract(start); + List parameters = new ArrayList<>(); + for (ProjectionSurface.Face face : view.faces()) { + Direction.Axis axis = face.normal().getAxis(); + double axisDelta = delta.get(axis); + if (Math.abs(axisDelta) <= SIDE_EPSILON) { + continue; + } + double parameter = (facePlane(face) - start.get(axis)) / axisDelta; + if (parameter > SIDE_EPSILON && parameter <= 1.0D + SIDE_EPSILON + && pointWithinFace(face, start.add(delta.scale(parameter)))) { + parameters.add(parameter); + } + } + parameters.sort(Double::compare); + double parameterOffset = Math.min(0.25D, Math.max(CROSSING_EPSILON * 4.0D, 1.0E-4D / delta.length())); + int crossings = 0; + int index = 0; + while (index < parameters.size()) { + double parameter = parameters.get(index); + int next = index + 1; + while (next < parameters.size() && parameters.get(next) - parameter <= CROSSING_EPSILON) { + next++; + } + Vec3 before = start.lerp(end, parameter - parameterOffset); + Vec3 after = start.lerp(end, parameter + parameterOffset); + if (classify(view, before) != classify(view, after)) { + crossings++; + } + index = next; + } + return crossings; + } + + private static double distanceToFaceSquared(ProjectionSurface.Face face, Vec3 point) { + BlockPos position = face.position(); + double x = Mth.clamp(point.x, position.getX(), position.getX() + 1.0D); + double y = Mth.clamp(point.y, position.getY(), position.getY() + 1.0D); + double z = Mth.clamp(point.z, position.getZ(), position.getZ() + 1.0D); + double plane = facePlane(face); + switch (face.normal().getAxis()) { + case X -> x = plane; + case Y -> y = plane; + case Z -> z = plane; + } + return point.distanceToSqr(x, y, z); + } + + private static double signedFaceDistance(ProjectionSurface.Face face, Vec3 point) { + return (point.get(face.normal().getAxis()) - facePlane(face)) * face.normal().getAxisDirection().getStep(); + } + + private static boolean pointWithinFace(ProjectionSurface.Face face, Vec3 point) { + BlockPos position = face.position(); + return switch (face.normal().getAxis()) { + case X -> withinFace(point.y, position.getY()) && withinFace(point.z, position.getZ()); + case Y -> withinFace(point.x, position.getX()) && withinFace(point.z, position.getZ()); + case Z -> withinFace(point.x, position.getX()) && withinFace(point.y, position.getY()); + }; + } + + private static boolean withinFace(double value, int minimum) { + return value >= minimum - SIDE_EPSILON && value <= minimum + 1.0D + SIDE_EPSILON; + } + + private static double facePlane(ProjectionSurface.Face face) { + int coordinate = face.position().get(face.normal().getAxis()); + return coordinate + (face.normal().getAxisDirection() == Direction.AxisDirection.POSITIVE ? 1.0D : 0.0D); + } + + private static boolean nearInteger(double value) { + return Math.abs(value - Math.rint(value)) <= SIDE_EPSILON; + } + + private static boolean segmentIntersectsBounds(AABB bounds, Vec3 start, Vec3 end) { + return bounds.contains(start) || bounds.contains(end) || bounds.clip(start, end).isPresent(); + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionCamera.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionCamera.java new file mode 100644 index 0000000..6ea941f --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionCamera.java @@ -0,0 +1,64 @@ +package dev.imb11.client.renderer.projection; + +import net.minecraft.client.Camera; +import net.minecraft.world.level.ChunkPos; +import dev.imb11.projection.ProjectionChunkRegion; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +public final class ProjectionCamera extends Camera { + private static final float NEAR_DISTANCE = 0.05F; + private final Matrix4f projection = new Matrix4f(); + private ChunkPos gridCenter; + + public ChunkPos retainGridCenter(Vec3 position) { + if (gridCenter == null || Math.abs(position.x - gridCenter.getMiddleBlockX()) > 16.0D + || Math.abs(position.z - gridCenter.getMiddleBlockZ()) > 16.0D) { + gridCenter = ProjectionChunkRegion.cameraCenter(position); + } + return gridCenter; + } + + public ChunkPos gridCenter() { + return gridCenter == null ? ProjectionChunkRegion.cameraCenter(position()) : gridCenter; + } + + public void setPose(Vec3 position, Quaternionf pose) { + Quaternionf normalizedPose = new Quaternionf(pose).normalize(); + Vector3f look = new Vector3f(0.0F, 0.0F, -1.0F).rotate(normalizedPose); + float yaw = (float) Math.toDegrees(Math.atan2(-look.x, look.z)); + float pitch = (float) Math.toDegrees(Math.asin(-Math.max(-1.0F, Math.min(1.0F, look.y)))); + setRotation(yaw, pitch); + rotation().set(normalizedPose); + setPosition(position.x, position.y, position.z); + } + + public void setProjection(Matrix4f projection) { + this.projection.set(projection); + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public net.minecraft.client.renderer.culling.Frustum getCullFrustum() { + var frustum = new net.minecraft.client.renderer.culling.Frustum(getViewRotationMatrix(new Matrix4f()), projection); + Vec3 position = position(); + frustum.prepare(position.x, position.y, position.z); + return frustum; + } + + @Override + public Matrix4f getViewRotationMatrix(Matrix4f dest) { + return dest.rotation(rotation().conjugate(new Quaternionf())); + } + + @Override + public Matrix4f getViewRotationProjectionMatrix(Matrix4f dest) { + return dest.set(projection).mul(getViewRotationMatrix(new Matrix4f())); + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionLevelRenderer.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionLevelRenderer.java new file mode 100644 index 0000000..fa4fe45 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionLevelRenderer.java @@ -0,0 +1,395 @@ +package dev.imb11.client.renderer.projection; + +import com.mojang.blaze3d.resource.GraphicsResourceAllocator; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.ProjectionType; +import dev.imb11.mixins.LevelRendererBufferAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.RenderBuffers; +import net.minecraft.client.renderer.SubmitNodeStorage; +import net.minecraft.client.renderer.ProjectionMatrixBuffer; +import net.minecraft.client.renderer.ViewArea; +import net.minecraft.client.renderer.chunk.SectionCompiler; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.feature.FeatureRenderDispatcher; +import net.minecraft.client.renderer.fog.FogRenderer; +import net.minecraft.client.renderer.state.GameRenderState; +import net.minecraft.core.SectionPos; +import net.minecraft.util.Util; +import net.minecraft.world.level.ChunkPos; +import org.joml.Matrix4f; + +import com.mojang.blaze3d.buffers.GpuBuffer; +import com.mojang.blaze3d.buffers.GpuBufferSlice; +import com.mojang.blaze3d.framegraph.FrameGraphBuilder; +import com.mojang.blaze3d.systems.RenderPass; +import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.imb11.mixins.ProjectionRendererAccessor; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import net.minecraft.client.Camera; +import net.minecraft.client.CloudStatus; +import net.minecraft.client.renderer.DynamicUniforms; +import net.minecraft.client.renderer.GlobalSettingsUniform; +import net.minecraft.client.renderer.SkyRenderer; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; +import net.minecraft.client.renderer.chunk.ChunkSectionsToRender; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.core.BlockPos; +import net.minecraft.util.ARGB; +import net.minecraft.util.profiling.Profiler; +import net.minecraft.world.level.block.entity.BlockEntity; +import org.joml.Matrix4fc; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; + +public class ProjectionLevelRenderer extends LevelRenderer { + private final Minecraft minecraft; + private final RenderBuffers buffers; + private final int renderDistance; + private final GameRenderState state; + private final FeatureRenderDispatcher features; + private final FogRenderer fog = new FogRenderer(); + private final ProjectionMatrixBuffer projectionBuffer = new ProjectionMatrixBuffer("GLASS projection"); + private final GlobalSettingsUniform globals = new GlobalSettingsUniform(); + private ClientLevel projectionLevel; + private SectionRenderDispatcher dispatcher; + private ChunkPos gridCenter; + + public ProjectionLevelRenderer(Minecraft minecraft, RenderBuffers buffers, int renderDistance) { + this(minecraft, buffers, renderDistance, new GameRenderState()); + } + + private ProjectionLevelRenderer(Minecraft minecraft, RenderBuffers buffers, int renderDistance, GameRenderState state) { + this(minecraft, buffers, renderDistance, state, new FeatureRenderDispatcher(new SubmitNodeStorage(), + minecraft.getModelManager(), buffers.bufferSource(), minecraft.getAtlasManager(), + buffers.outlineBufferSource(), buffers.crumblingBufferSource(), minecraft.font, state)); + } + + private ProjectionLevelRenderer(Minecraft minecraft, RenderBuffers buffers, int renderDistance, + GameRenderState state, FeatureRenderDispatcher features) { + super(minecraft, minecraft.getEntityRenderDispatcher(), minecraft.getBlockEntityRenderDispatcher(), buffers, state, features); + this.minecraft = minecraft; + this.buffers = buffers; + this.renderDistance = renderDistance; + this.state = state; + this.features = features; + ((ProjectionRendererAccessor) this).glass$skyRenderer(new SkyRenderer(minecraft.getTextureManager(), minecraft.getAtlasManager())); + } + + @Override + public void setLevel(ClientLevel level) { + LevelRendererBufferAccessor accessor = (LevelRendererBufferAccessor) this; + ViewArea area = accessor.glass$getViewArea(); + if (area != null) { + area.releaseAllBuffers(); + accessor.glass$setViewArea(null); + } + if (dispatcher != null) { + dispatcher.dispose(); + dispatcher = null; + accessor.glass$setSectionRenderDispatcher(null); + } + accessor.glass$getVisibleSections().clear(); + projectionLevel = level; + accessor.glass$setLevel(level); + gridCenter = null; + if (level != null) { + allChanged(); + } + } + + @Override + public void allChanged() { + if (projectionLevel == null) { + return; + } + LevelRendererBufferAccessor accessor = (LevelRendererBufferAccessor) this; + if (accessor.glass$getViewArea() != null) { + accessor.glass$getViewArea().releaseAllBuffers(); + } + var models = minecraft.getModelManager(); + SectionCompiler compiler = new SectionCompiler(minecraft.options.ambientOcclusion().get(), + minecraft.options.cutoutLeaves().get(), models.getBlockStateModelSet(), models.getFluidStateModelSet(), + minecraft.getBlockColors(), minecraft.getBlockEntityRenderDispatcher()); + if (dispatcher == null) { + dispatcher = new SectionRenderDispatcher(projectionLevel, this, Util.backgroundExecutor(), buffers, compiler); + accessor.glass$setSectionRenderDispatcher(dispatcher); + } else { + dispatcher.clearCompileQueue(); + dispatcher.setLevel(projectionLevel, compiler); + } + accessor.glass$getVisibleSections().clear(); + accessor.glass$setViewArea(new ViewArea(dispatcher, projectionLevel, renderDistance, this)); + gridCenter = null; + } + + public void prepareCamera(ProjectionCamera camera) { + ChunkPos center = camera.gridCenter(); + if (!center.equals(gridCenter)) { + ((LevelRendererBufferAccessor) this).glass$getViewArea().repositionCamera( + SectionPos.of(center.x(), SectionPos.blockToSectionCoord(camera.position().y), center.z())); + gridCenter = center; + } + dispatcher.setCameraPosition(camera.position()); + } + + public void renderScene(ProjectionCamera camera, Matrix4f projection, DeltaTracker deltaTracker) { + var savedProjection = RenderSystem.getProjectionMatrixBuffer(); + var savedFog = RenderSystem.getShaderFog(); + var savedGlobals = RenderSystem.getGlobalSettingsUniform(); + var options = state.optionsRenderState; + var mainOptions = minecraft.gameRenderer.getGameRenderState().optionsRenderState; + options.cloudStatus = mainOptions.cloudStatus; + options.cloudRange = mainOptions.cloudRange; + options.renderDistance = renderDistance; + options.ambientOcclusion = mainOptions.ambientOcclusion; + options.cutoutLeaves = mainOptions.cutoutLeaves; + options.textureFiltering = mainOptions.textureFiltering; + options.maxAnisotropyValue = mainOptions.maxAnisotropyValue; + options.improvedTransparency = false; + options.cameraType = mainOptions.cameraType; + options.glintSpeed = mainOptions.glintSpeed; + options.glintStrength = mainOptions.glintStrength; + options.textBackgroundOpacity = mainOptions.textBackgroundOpacity; + options.backgroundForChatOnly = mainOptions.backgroundForChatOnly; + var cameraState = state.levelRenderState.cameraRenderState; + float partialTick = deltaTracker.getGameTimeDeltaPartialTick(false); + camera.extractRenderState(cameraState, partialTick); + cameraState.projectionMatrix.set(projection); + cameraState.viewRotationMatrix.set(camera.getViewRotationMatrix(new Matrix4f())); + cameraState.cullFrustum = camera.getCullFrustum(); + cameraState.initialized = true; + cameraState.depthFar = renderDistance * 64.0F; + cameraState.fogData = fog.setupFog(camera, renderDistance, deltaTracker, 0.0F, projectionLevel); + state.levelRenderState.reset(); + try { + extractLevel(deltaTracker, camera, partialTick); + state.levelRenderState.particlesRenderState.reset(); + fog.updateBuffer(cameraState.fogData); + RenderSystem.setProjectionMatrix(projectionBuffer.getBuffer(projection), ProjectionType.PERSPECTIVE); + var target = minecraft.getMainRenderTarget(); + globals.update(target.width, target.height, mainOptions.glintStrength, projectionLevel.getGameTime(), + deltaTracker, 0, camera.position(), false); + renderProjectionLevel(deltaTracker); + } finally { + features.clearSubmitNodes(); + features.endFrame(); + endFrame(); + fog.endFrame(); + RenderSystem.setProjectionMatrix(savedProjection, ProjectionType.PERSPECTIVE); + RenderSystem.setShaderFog(savedFog); + RenderSystem.setGlobalSettingsUniform(savedGlobals); + minecraft.getEntityRenderDispatcher().prepare(minecraft.gameRenderer.getMainCamera(), minecraft.crosshairPickEntity); + minecraft.getBlockEntityRenderDispatcher().prepare(minecraft.gameRenderer.getMainCamera().position()); + } + } + + private void renderProjectionLevel(DeltaTracker delta) { + var access = (ProjectionRendererAccessor) this; + var targets = access.glass$targets(); + var levelState = state.levelRenderState; + var camera = levelState.cameraRenderState; + var terrainFog = fog.getBuffer(FogRenderer.FogMode.WORLD); + levelState.gameTime = projectionLevel.getGameTime(); + var modelView = RenderSystem.getModelViewStack(); + modelView.pushMatrix(); + modelView.mul(camera.viewRotationMatrix); + try { + var frame = new FrameGraphBuilder(); + var target = minecraft.getMainRenderTarget(); + targets.main = frame.importExternal("main", target); + var clear = frame.addPass("clear"); + targets.main = clear.readsAndWrites(targets.main); + clear.executes(() -> RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( + target.getColorTexture(), ARGB.colorFromFloat(1.0F, camera.fogData.color.x, camera.fogData.color.y, + camera.fogData.color.z), target.getDepthTexture(), 1.0)); + access.glass$skyPass(frame, camera, terrainFog); + access.glass$mainPass(frame, camera.cullFrustum, camera.viewRotationMatrix, terrainFog, false, + levelState, delta, Profiler.get(), levelState.chunkSectionsToRender); + if (state.optionsRenderState.cloudStatus != CloudStatus.OFF && ARGB.alpha(levelState.cloudColor) > 0) { + access.glass$cloudsPass(frame, state.optionsRenderState.cloudStatus, camera.pos, levelState.gameTime, + delta.getGameTimeDeltaPartialTick(false), levelState.cloudColor, levelState.cloudHeight, + state.optionsRenderState.cloudRange); + } + access.glass$weatherPass(frame, terrainFog); + frame.execute(GraphicsResourceAllocator.UNPOOLED); + } finally { + targets.clear(); + modelView.popMatrix(); + levelState.reset(); + } + } + + public void extractProjectionBlockEntities(float partialTick, LevelRenderState levelState) { + var dispatcher = minecraft.getBlockEntityRenderDispatcher(); + var hidden = ProjectionRenderContext.hiddenTerrainBlock(); + for (var section : ((LevelRendererBufferAccessor) this).glass$getVisibleSections()) { + for (BlockEntity entity : section.getSectionMesh().getRenderableBlockEntities()) { + if (!entity.getBlockPos().equals(hidden)) { + var entityState = dispatcher.tryExtractRenderState(entity, partialTick, null); + if (entityState != null) { + levelState.blockEntityRenderStates.add(entityState); + } + } + } + } + for (BlockEntity entity : projectionLevel.getGloballyRenderedBlockEntities()) { + if (!entity.isRemoved() && !entity.getBlockPos().equals(hidden)) { + var entityState = dispatcher.tryExtractRenderState(entity, partialTick, null); + if (entityState != null) { + levelState.blockEntityRenderStates.add(entityState); + } + } + } + } + + @Override + public void tick(Camera camera) { + ((ProjectionRendererAccessor) this).glass$ticks((int) projectionLevel.getGameTime()); + } + + @Override + public void addRecentlyCompiledSection(SectionRenderDispatcher.RenderSection section) { + } + + @Override + public void onChunkReadyToRender(ChunkPos pos) { + } + + @Override + public boolean isSectionCompiledAndVisible(BlockPos pos) { + var section = ProjectionSections.find(this, SectionPos.blockToSectionCoord(pos.getX()), + SectionPos.blockToSectionCoord(pos.getY()), SectionPos.blockToSectionCoord(pos.getZ())); + return section != null && section.getSectionMesh() != net.minecraft.client.renderer.chunk.CompiledSectionMesh.UNCOMPILED; + } + + @Override + public void needsUpdate() { + } + + @Override + public SectionRenderDispatcher getSectionRenderDispatcher() { + return dispatcher; + } + + @Override + public void close() { + setLevel(null); + super.close(); + features.close(); + fog.close(); + projectionBuffer.close(); + globals.close(); + } + @Override + public ChunkSectionsToRender prepareChunkRenders(final Matrix4fc modelViewMatrix) { + var iterator = ((LevelRendererBufferAccessor) this).glass$getVisibleSections().listIterator(0); + EnumMap>>> drawGroups = new EnumMap<>(ChunkSectionLayer.class); + int largestIndexCount = 0; + + for (ChunkSectionLayer layer : ChunkSectionLayer.values()) { + drawGroups.put(layer, new Int2ObjectOpenHashMap()); + } + + List sectionInfos = new ArrayList<>(); + GpuTextureView blockAtlas = this.minecraft.getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(); + int textureAtlasWidth = blockAtlas.getWidth(0); + int textureAtlasHeight = blockAtlas.getHeight(0); + if (dispatcher != null) { + dispatcher.lock(); + + try { + try (var ignored = Profiler.get().zone("Upload Global Buffers")) { + dispatcher.uploadGlobalGeomBuffersToGPU(); + } + + while (iterator.hasNext()) { + SectionRenderDispatcher.RenderSection section = (SectionRenderDispatcher.RenderSection)iterator.next(); + SectionMesh sectionMesh = section.getSectionMesh(); + BlockPos renderOffset = section.getRenderOrigin(); + long now = Util.getMillis(); + int uboIndex = -1; + + for (ChunkSectionLayer layer : ChunkSectionLayer.values()) { + SectionMesh.SectionDraw draw = sectionMesh.getSectionDraw(layer); + SectionRenderDispatcher.RenderSectionBufferSlice slice = dispatcher.getRenderSectionSlice(sectionMesh, layer); + if (slice != null && draw != null && (!draw.hasCustomIndexBuffer() || slice.indexBuffer() != null)) { + if (uboIndex == -1) { + uboIndex = sectionInfos.size(); + sectionInfos.add( + new DynamicUniforms.ChunkSectionInfo( + new Matrix4f(modelViewMatrix), + renderOffset.getX(), + renderOffset.getY(), + renderOffset.getZ(), + section.getVisibility(now), + textureAtlasWidth, + textureAtlasHeight + ) + ); + } + + int combinedHash = 173; + VertexFormat vertexFormat = layer.pipeline().getVertexFormat(); + GpuBuffer vertexBuffer = slice.vertexBuffer(); + if (layer != ChunkSectionLayer.TRANSLUCENT) { + combinedHash = 31 * combinedHash + vertexBuffer.hashCode(); + } + + int firstIndex = 0; + GpuBuffer indexBuffer; + VertexFormat.IndexType indexType; + if (!draw.hasCustomIndexBuffer()) { + if (draw.indexCount() > largestIndexCount) { + largestIndexCount = draw.indexCount(); + } + + indexBuffer = null; + indexType = null; + } else { + indexBuffer = slice.indexBuffer(); + indexType = draw.indexType(); + if (layer != ChunkSectionLayer.TRANSLUCENT) { + combinedHash = 31 * combinedHash + indexBuffer.hashCode(); + combinedHash = 31 * combinedHash + indexType.hashCode(); + } + + firstIndex = (int)(slice.indexBufferOffset() / indexType.bytes); + } + + int finalUboIndex = uboIndex; + int baseVertex = (int)(slice.vertexBufferOffset() / vertexFormat.getVertexSize()); + List> draws = (List>)drawGroups.get(layer) + .computeIfAbsent(combinedHash, var0 -> new ArrayList()); + draws.add( + new RenderPass.Draw<>( + 0, + vertexBuffer, + indexBuffer, + indexType, + firstIndex, + draw.indexCount(), + baseVertex, + (sectionUbos, uploader) -> uploader.upload("ChunkSection", sectionUbos[finalUboIndex]) + ) + ); + } + } + } + } finally { + dispatcher.unlock(); + } + } + + GpuBufferSlice[] chunkSectionInfos = RenderSystem.getDynamicUniforms().writeChunkSections(sectionInfos.toArray(new DynamicUniforms.ChunkSectionInfo[0])); + return new ChunkSectionsToRender(blockAtlas, drawGroups, largestIndexCount, chunkSectionInfos); + } + +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionLightmap.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionLightmap.java new file mode 100644 index 0000000..7bea285 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionLightmap.java @@ -0,0 +1,22 @@ +package dev.imb11.client.renderer.projection; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.Lightmap; +import net.minecraft.client.renderer.state.LightmapRenderState; +import net.minecraft.util.ARGB; +import net.minecraft.world.attribute.EnvironmentAttributes; + +public final class ProjectionLightmap extends Lightmap { + public void update(ProjectionCamera camera, float partialTick) { + LightmapRenderState state = new LightmapRenderState(); + state.needsUpdate = true; + state.blockFactor = 1.4F; + state.blockLightTint = ARGB.vector3fFromRGB24(camera.attributeProbe().getValue(EnvironmentAttributes.BLOCK_LIGHT_TINT, partialTick)); + state.skyFactor = camera.attributeProbe().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, partialTick); + state.skyLightColor = ARGB.vector3fFromRGB24(camera.attributeProbe().getValue(EnvironmentAttributes.SKY_LIGHT_COLOR, partialTick)); + state.ambientColor = ARGB.vector3fFromRGB24(camera.attributeProbe().getValue(EnvironmentAttributes.AMBIENT_LIGHT_COLOR, partialTick)); + state.nightVisionColor = ARGB.vector3fFromRGB24(camera.attributeProbe().getValue(EnvironmentAttributes.NIGHT_VISION_COLOR, partialTick)); + state.brightness = Minecraft.getInstance().options.gamma().get().floatValue(); + render(state); + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionMesh.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionMesh.java new file mode 100644 index 0000000..c7f5ae4 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionMesh.java @@ -0,0 +1,54 @@ +package dev.imb11.client.renderer.projection; + +import com.mojang.blaze3d.buffers.GpuBuffer; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.textures.FilterMode; +import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexFormat; +import net.minecraft.client.Minecraft; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.joml.Vector4f; + +import java.util.Map; +import java.util.OptionalDouble; +import java.util.OptionalInt; + +public final class ProjectionMesh implements AutoCloseable { + private final GpuBuffer vertices; + private final int indexCount; + + public ProjectionMesh(MeshData mesh) { + try (mesh) { + vertices = RenderSystem.getDevice().createBuffer(() -> "GLASS mesh", GpuBuffer.USAGE_VERTEX, mesh.vertexBuffer()); + indexCount = mesh.drawState().indexCount(); + } + } + + public void draw(RenderPipeline pipeline, Matrix4f pose, Map textures, GpuBuffer parameters) { + var target = Minecraft.getInstance().getMainRenderTarget(); + var indices = RenderSystem.getSequentialBuffer(VertexFormat.Mode.QUADS); + var indexBuffer = indices.getBuffer(indexCount); + var transforms = RenderSystem.getDynamicUniforms().writeTransform(pose, new Vector4f(1), new Vector3f(), new Matrix4f()); + try (var pass = RenderSystem.getDevice().createCommandEncoder().createRenderPass(() -> "GLASS projection surface", + target.getColorTextureView(), OptionalInt.empty(), target.getDepthTextureView(), OptionalDouble.empty())) { + pass.setPipeline(pipeline); + RenderSystem.bindDefaultUniforms(pass); + pass.setUniform("DynamicTransforms", transforms); + if (parameters != null) { + pass.setUniform("SurfaceParameters", parameters); + } + textures.forEach((name, texture) -> pass.bindTexture(name, texture, RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR))); + pass.setVertexBuffer(0, vertices); + pass.setIndexBuffer(indexBuffer, indices.type()); + pass.drawIndexed(0, 0, indexCount, 1); + } + } + + @Override + public void close() { + vertices.close(); + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderBuffers.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderBuffers.java new file mode 100644 index 0000000..36b52a5 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderBuffers.java @@ -0,0 +1,19 @@ +package dev.imb11.client.renderer.projection; + +import net.minecraft.client.renderer.RenderBuffers; +import net.minecraft.client.renderer.SectionBufferBuilderPool; + +public final class ProjectionRenderBuffers extends RenderBuffers { + private final SectionBufferBuilderPool projectionPool; + + public ProjectionRenderBuffers(int bufferCount) { + super(bufferCount); + SectionBufferBuilderPool pool = super.sectionBufferPool(); + projectionPool = pool.getFreeBufferCount() > 0 ? pool : SectionBufferBuilderPool.allocate(bufferCount); + } + + @Override + public SectionBufferBuilderPool sectionBufferPool() { + return projectionPool; + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderContext.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderContext.java new file mode 100644 index 0000000..1bc8436 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderContext.java @@ -0,0 +1,139 @@ +package dev.imb11.client.renderer.projection; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import net.minecraft.client.Camera; +import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import dev.imb11.client.renderer.projection.ProjectionLightmap; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.LightLayer; + +import java.util.IdentityHashMap; +import java.util.Map; + +public final class ProjectionRenderContext { + private static final int MAX_FEED_RENDER_DISTANCE_CHUNKS = 6; + private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + private static final Map REMOTE_LIGHT_UPDATES = new IdentityHashMap<>(); + + private ProjectionRenderContext() { + } + + public static boolean isActive() { + return ACTIVE.get() != null; + } + + public static RenderTarget target() { + State state = ACTIVE.get(); + return state == null ? null : state.target; + } + + public static BlockPos hiddenTerrainBlock() { + State state = ACTIVE.get(); + return state == null ? null : state.hiddenTerrainBlock; + } + + public static ClientLevel level(ClientLevel fallback) { + State state = ACTIVE.get(); + return state == null || state.level == null ? fallback : state.level; + } + + public static ClientLevel level(LevelRenderer renderer, ClientLevel fallback) { + State state = ACTIVE.get(); + return state == null || state.renderer != renderer || state.level == null ? fallback : state.level; + } + + public static Camera camera(LevelRenderer renderer, Camera fallback) { + State state = ACTIVE.get(); + return state != null && state.renderer == renderer ? state.camera : fallback; + } + + public static Camera camera() { + State state = ACTIVE.get(); + return state == null ? null : state.camera; + } + + public static ProjectionLightmap lightTexture() { + State state = ACTIVE.get(); + return state == null ? null : state.lightTexture; + } + + public static int feedRenderDistance(int configuredDistance) { + return Math.min(configuredDistance, MAX_FEED_RENDER_DISTANCE_CHUNKS); + } + + public static int feedRenderDistance(int configuredDistance, int grantedDistance) { + return Math.min(configuredDistance, Math.min(MAX_FEED_RENDER_DISTANCE_CHUNKS, Math.max(1, grantedDistance))); + } + + public static Scope enter(LevelRenderer renderer, Camera camera, RenderTarget target, BlockPos hiddenTerrainBlock, + ClientLevel level, ProjectionLightmap lightTexture) { + State previous = ACTIVE.get(); + ACTIVE.set(new State(renderer, camera, target, hiddenTerrainBlock.immutable(), level, lightTexture)); + return new Scope(previous); + } + + public static void registerRemoteLightUpdates(ClientChunkCache chunkCache, LightUpdateListener listener) { + synchronized (REMOTE_LIGHT_UPDATES) { + REMOTE_LIGHT_UPDATES.put(chunkCache, listener); + } + } + + public static void unregisterRemoteLightUpdates(ClientChunkCache chunkCache) { + synchronized (REMOTE_LIGHT_UPDATES) { + REMOTE_LIGHT_UPDATES.remove(chunkCache); + } + } + + public static boolean routeRemoteLightUpdate(ClientChunkCache chunkCache, LightLayer layer, SectionPos sectionPos) { + LightUpdateListener listener; + synchronized (REMOTE_LIGHT_UPDATES) { + listener = REMOTE_LIGHT_UPDATES.get(chunkCache); + } + if (listener == null) { + return false; + } + listener.onLightUpdate(layer, sectionPos); + return true; + } + + private record State( + LevelRenderer renderer, + Camera camera, + RenderTarget target, + BlockPos hiddenTerrainBlock, + ClientLevel level, + ProjectionLightmap lightTexture + ) { + } + + @FunctionalInterface + public interface LightUpdateListener { + void onLightUpdate(LightLayer layer, SectionPos sectionPos); + } + + public static final class Scope implements AutoCloseable { + private final State previous; + private boolean closed; + + private Scope(State previous) { + this.previous = previous; + } + + @Override + public void close() { + if (closed) { + return; + } + + closed = true; + if (previous == null) { + ACTIVE.remove(); + } else { + ACTIVE.set(previous); + } + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderManager.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderManager.java new file mode 100644 index 0000000..15961b1 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderManager.java @@ -0,0 +1,1401 @@ +package dev.imb11.client.renderer.projection; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexSorting; +import com.mojang.blaze3d.vertex.PoseStack; +import dev.imb11.client.remote.RemoteSceneClientManager; +import dev.imb11.debug.FeedDiagnostics; +import dev.imb11.debug.FeedTrace; +import dev.imb11.client.gui.ChannelScreen; +import dev.imb11.client.remote.ProjectionChunkStorage; +import dev.imb11.client.remote.RemoteSceneHandle; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.client.renderer.block.ProjectorBlockEntityRenderer; +import dev.imb11.mixins.LevelRendererBufferAccessor; +import dev.imb11.projection.ProjectionSurface; +import dev.imb11.projection.ProjectionChunkRegion; +import dev.imb11.sync.ProjectionSource; +import dev.imb11.sync.remote.RemoteSubscriptionId; +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.minecraft.client.Camera; +import net.minecraft.client.CloudStatus; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.LevelRenderer; +import dev.imb11.client.renderer.projection.ProjectionLightmap; +import net.minecraft.client.renderer.RenderBuffers; +import net.minecraft.client.renderer.SectionBufferBuilderPool; +import net.minecraft.client.renderer.ViewArea; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.chunk.SectionMesh; +import com.mojang.blaze3d.textures.GpuTexture; +import com.mojang.blaze3d.textures.GpuTextureView; +import net.minecraft.client.renderer.chunk.RenderRegionCache; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.client.renderer.texture.AbstractTexture; +import net.minecraft.client.renderer.texture.TextureManager; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.SectionPos; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.Mth; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix3f; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.function.Predicate; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public final class ProjectionRenderManager { + private static final Logger LOGGER = LoggerFactory.getLogger("glass/projection-renderer"); + private static final int REQUEST_RENDER_GRACE_FRAMES = 2; + private static final long FEED_RETENTION_NANOS = TimeUnit.SECONDS.toNanos(10L); + private static final long PREVIEW_RETENTION_NANOS = TimeUnit.MINUTES.toNanos(2L); + private static final int FAILURE_RETRY_FRAMES = 60; + private static final int STRANDED_BUILD_FRAMES = 10; + private static final int FEED_BUILD_BUFFERS = Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() - 1)); + private static final long BUILD_PREPARATION_BUDGET_NANOS = TimeUnit.MILLISECONDS.toNanos(2L); + private static final long UPLOAD_BUDGET_NANOS = TimeUnit.MILLISECONDS.toNanos(2L); + private static final float CAMERA_FACE_OFFSET = 0.5625F; + private static final Map FEEDS = new LinkedHashMap<>(); + private static final Map TERRAINS = new HashMap<>(); + private static final Map PORTAL_SIDES = new HashMap<>(); + private static final List RETIRED_BUFFER_POOLS = new ArrayList<>(); + private static Map registrySources = Map.of(); + private static ClientLevel activeLevel; + private static long frameSequence; + private static long textureSequence; + private static long subscriptionSequence; + private static long buildPreparationNanos; + private static long uploadNanos; + + private ProjectionRenderManager() { + } + + @Nullable + public static ProjectionFeed requestFeed( + @Nullable ProjectionSource source, + BlockPos projectorPos, + ProjectionSurface surface + ) { + RenderSystem.assertOnRenderThread(); + Minecraft minecraft = Minecraft.getInstance(); + ClientLevel currentLevel = minecraft.level; + if (source == null || currentLevel == null) { + return null; + } + if (activeLevel != currentLevel) { + changeLevelNow(currentLevel); + } + if (!source.equals(registrySources.get(source.channel()))) { + return null; + } + + ProjectionView view = ProjectionView.create(currentLevel.dimension(), projectorPos, surface); + PortalSide side = portalSide(view); + side.track(view, minecraft.gameRenderer.getMainCamera().position(), frameSequence); + FeedKey key = new FeedKey(source.key(), view.dimension(), view.projectorPos(), false); + closeFeeds(other -> !other.key.equals(key) && !other.key.preview() + && other.key.projectorDimension().equals(key.projectorDimension()) + && other.key.projectorPos().equals(key.projectorPos())); + ProjectionFeed feed = FEEDS.get(key); + if (feed != null && !feed.source.equals(source)) { + FEEDS.remove(key); + closeFeed(feed); + feed = null; + } + if (feed == null) { + feed = new ProjectionFeed(key, source, view, side, nextTextureLocation()); + FEEDS.put(key, feed); + } else if (!feed.view.equals(view)) { + ProjectionAlignment alignment = feed.alignment; + if (alignment != null && alignment.anchor.projectorPos().equals(view.projectorPos()) + && !alignment.anchor.sameFrame(view)) { + for (ProjectionFeed aligned : FEEDS.values()) { + if (aligned.alignment == alignment) { + aligned.alignment = null; + aligned.ready = false; + } + } + } + feed.view = view; + feed.ready = false; + feed.stage = ProjectionStage.SURFACE_CHANGED; + } + feed.lastRequestFrame = frameSequence; + feed.lastRequestNanos = System.nanoTime(); + return feed; + } + + public static ProjectionFeed requestPreview(ProjectionSource source, BlockPos ownerPos, float aspect) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null || source == null || !source.equals(registrySources.get(source.channel()))) { + return null; + } + FeedKey key = new FeedKey(source.key(), minecraft.level.dimension(), ownerPos, true); + ProjectionFeed feed = FEEDS.get(key); + if (feed == null || !feed.source.equals(source)) { + if (feed != null) { + FEEDS.remove(key); + closeFeed(feed); + } + evictIdleFeeds(1); + ProjectionView view = new ProjectionView(minecraft.level.dimension(), ownerPos, ownerPos, + Direction.NORTH, Direction.EAST, Direction.UP, List.of(), new AABB(ownerPos)); + feed = new ProjectionFeed(key, source, view, new PortalSide(), nextTextureLocation()); + FEEDS.put(key, feed); + } + feed.previewAspect = Math.max(0.25F, aspect); + feed.lastRequestFrame = frameSequence; + feed.lastRequestNanos = System.nanoTime(); + feed.lastVisibleFrame = frameSequence; + return feed; + } + + public static void prepareRequests(List projectors) { + Set requested = new LinkedHashSet<>(); + for (ProjectorBlockEntity projector : projectors) { + ProjectionSource source = registrySources.get(projector.getChannel()); + if (source != null && activeLevel != null) { + requested.add(new FeedKey(source.key(), activeLevel.dimension(), projector.getBlockPos(), false)); + } + } + long newFeeds = requested.stream().filter(key -> !FEEDS.containsKey(key)).count(); + evictFeeds(feed -> !requested.contains(feed.key), newFeeds); + } + + private static void evictIdleFeeds(long newFeeds) { + evictFeeds(feed -> feed.lastRequestFrame != frameSequence, newFeeds); + } + + private static void evictFeeds(Predicate evictable, long newFeeds) { + List retained = FEEDS.values().stream() + .filter(evictable) + .sorted(Comparator.comparingLong(feed -> feed.lastRequestNanos)) + .toList(); + for (ProjectionFeed feed : retained) { + if (FEEDS.size() + newFeeds <= RemoteSceneServerManager.MAX_SUBSCRIPTIONS_PER_PLAYER) { + break; + } + FEEDS.remove(feed.key); + closeFeed(feed); + } + } + + public static boolean isReady(@Nullable ProjectionFeed feed) { + return feed != null + && FEEDS.get(feed.key) == feed + && feed.ready + && feed.target != null + && feed.target.getColorTextureView() != null; + } + + public static void alignFeeds(List projectors) { + List pending = new ArrayList<>(); + for (ProjectorBlockEntity projector : projectors) { + ProjectionFeed feed = preparedFeed(registrySources.get(projector.getChannel()), + projector.getBlockPos(), projector.getProjectionSurface()); + if (feed != null && projector.isProjectionVisible()) { + pending.add(feed); + } + } + for (ProjectionFeed feed : FEEDS.values()) { + if (!pending.contains(feed)) { + feed.alignment = null; + } + } + pending.sort(Comparator.comparingLong((ProjectionFeed feed) -> + feed.alignment == null ? Long.MAX_VALUE : feed.alignment.createdFrame) + .thenComparingLong(feed -> feed.view.projectorPos().asLong())); + Set used = Collections.newSetFromMap(new IdentityHashMap<>()); + Vec3 viewer = Minecraft.getInstance().gameRenderer.getMainCamera().position(); + while (!pending.isEmpty()) { + ProjectionFeed first = pending.removeFirst(); + List connected = new ArrayList<>(); + connected.add(first); + Map faces = new LinkedHashMap<>(); + for (ProjectionSurface.Face face : first.view.faces()) { + faces.put(new SurfaceFaceKey(face.position(), face.normal()), face); + } + AABB bounds = first.view.bounds(); + boolean expanded; + do { + expanded = false; + Iterator iterator = pending.iterator(); + while (iterator.hasNext()) { + ProjectionFeed candidate = iterator.next(); + if (!first.source.equals(candidate.source) + || candidate.view.faces().stream().noneMatch(face -> + faces.containsKey(new SurfaceFaceKey(face.position(), face.normal())))) { + continue; + } + iterator.remove(); + connected.add(candidate); + bounds = bounds.minmax(candidate.view.bounds()); + for (ProjectionSurface.Face face : candidate.view.faces()) { + faces.putIfAbsent(new SurfaceFaceKey(face.position(), face.normal()), face); + } + expanded = true; + } + } while (expanded); + ProjectionAlignment alignment = first.alignment; + if (alignment == null || !used.add(alignment)) { + ProjectionAlignment replacement = new ProjectionAlignment(alignment == null ? first.view : alignment.anchor); + replacement.side.inheritSide(alignment == null ? first.side : alignment.side); + alignment = replacement; + used.add(alignment); + } + ProjectionView anchor = alignment.anchor; + ProjectionView combined = new ProjectionView(anchor.dimension(), anchor.projectorPos(), + anchor.origin(), anchor.facing(), anchor.uDirection(), anchor.vDirection(), + List.copyOf(faces.values()), bounds); + alignment.side.track(combined, viewer, frameSequence); + for (ProjectionFeed feed : connected) { + if (feed.alignment != alignment) { + feed.ready = false; + feed.alignment = alignment; + } + } + } + } + + public static boolean usesSharedTerrain(LevelRenderer renderer) { + for (TerrainResources terrain : TERRAINS.values()) { + if (terrain.renderer == renderer) { + return terrain.references > 1; + } + } + return false; + } + + @Nullable + public static ProjectionFeed visibleFeed(@Nullable ProjectionSource source, BlockPos projectorPos, ProjectionSurface surface) { + ProjectionFeed feed = preparedFeed(source, projectorPos, surface); + if (feed != null) { + markVisible(feed); + } + return feed; + } + + @Nullable + public static ProjectionFeed preparedFeed(@Nullable ProjectionSource source, BlockPos projectorPos, ProjectionSurface surface) { + if (source == null || activeLevel == null) { + return null; + } + ProjectionFeed feed = FEEDS.get(new FeedKey(source.key(), activeLevel.dimension(), projectorPos, false)); + if (feed == null || feed.lastRequestFrame != frameSequence + || !feed.source.equals(source) + || !feed.view.equals(ProjectionView.create(activeLevel.dimension(), projectorPos, surface))) { + return null; + } + return feed; + } + + static void markVisible(ProjectionFeed feed) { + feed.lastVisibleFrame = frameSequence; + } + + public static long currentFrameSequence() { + return frameSequence; + } + + public static boolean hasVisibleProjection() { + for (ProjectionFeed feed : FEEDS.values()) { + if (feed.ready && frameSequence - feed.lastRequestFrame <= REQUEST_RENDER_GRACE_FRAMES) { + return true; + } + } + return false; + } + + public static void trackViewer( + ClientLevel level, + BlockPos projectorPos, + ProjectionSurface surface, + Vec3 viewerPosition + ) { + RenderSystem.assertOnRenderThread(); + if (activeLevel != level) { + changeLevelNow(level); + } + ProjectionView view = ProjectionView.create(level.dimension(), projectorPos, surface); + portalSide(view).track(view, viewerPosition, frameSequence); + } + + public static void releaseProjector(ClientLevel level, BlockPos projectorPos) { + BlockPos immutablePos = projectorPos.immutable(); + ResourceKey dimension = level.dimension(); + RenderResources.runOnRenderThread(() -> releaseProjectorNow(dimension, immutablePos)); + } + + public static void releasePreview(ClientLevel level, BlockPos ownerPos) { + BlockPos immutablePos = ownerPos.immutable(); + ResourceKey dimension = level.dimension(); + RenderResources.runOnRenderThread(() -> releasePreviewNow(dimension, immutablePos)); + } + + public static boolean isProjectionRenderer(LevelRenderer candidate) { + if (candidate == null) { + return false; + } + for (ProjectionFeed feed : FEEDS.values()) { + if (feed.renderer() == candidate) { + return true; + } + } + return false; + } + + public static void onRegistryReplaced(Map sources) { + Map replacement = new HashMap<>(); + sources.forEach((channel, source) -> { + if (channel != null && source != null && channel.equals(source.channel())) { + replacement.put(channel, source); + } + }); + Map immutableReplacement = Map.copyOf(replacement); + RenderResources.runOnRenderThread(() -> replaceRegistryNow(immutableReplacement)); + } + + public static void onClientChunkUnloaded(ClientLevel level, ChunkPos chunkPos) { + RenderResources.runOnRenderThread(() -> chunkUnloadedNow(level, chunkPos)); + } + + public static void onClientLevelChanged(@Nullable ClientLevel level) { + RenderResources.runOnRenderThread(() -> { + if (activeLevel != level) { + changeLevelNow(level); + } + }); + } + + public static void onMainRendererRebuilt(LevelRenderer candidate) { + Minecraft minecraft = Minecraft.getInstance(); + if (candidate != minecraft.levelRenderer) { + return; + } + RenderResources.runOnRenderThread(() -> { + for (ProjectionFeed feed : List.copyOf(FEEDS.values())) { + disposeFeedResources(feed); + } + }); + } + + public static void onMainSectionDirty(LevelRenderer candidate, int sectionX, int sectionY, int sectionZ) { + Minecraft minecraft = Minecraft.getInstance(); + if (candidate != minecraft.levelRenderer) { + return; + } + for (ProjectionFeed feed : List.copyOf(FEEDS.values())) { + if (feed.level() == activeLevel + && feed.renderer() != null + && sectionInFeedView(feed, sectionX, sectionY, sectionZ)) { + ProjectionSections.markDirty(feed.renderer(), sectionX, sectionY, sectionZ); + } + } + } + + public static void onMainChunkLoaded(LevelRenderer candidate, ChunkPos chunkPos) { + Minecraft minecraft = Minecraft.getInstance(); + if (candidate != minecraft.levelRenderer) { + return; + } + for (ProjectionFeed feed : List.copyOf(FEEDS.values())) { + if (feed.level() == activeLevel && feed.renderer() != null) { + feed.renderer().onChunkReadyToRender(chunkPos); + dirtyChunkSections(feed, chunkPos); + } + } + } + + public static void onMainRendererTick(LevelRenderer candidate) { + Minecraft minecraft = Minecraft.getInstance(); + if (candidate != minecraft.levelRenderer) { + return; + } + Set ticked = Collections.newSetFromMap(new IdentityHashMap<>()); + for (ProjectionFeed feed : List.copyOf(FEEDS.values())) { + if (feed.renderer() != null && ticked.add(feed.renderer())) { + feed.renderer().tick(feed.camera); + } + } + } + + public static void renderBeforeMain(GameRenderer gameRenderer, DeltaTracker deltaTracker) { + if (ProjectionRenderContext.isActive()) { + return; + } + RenderSystem.assertOnRenderThread(); + frameSequence++; + buildPreparationNanos = 0L; + uploadNanos = 0L; + + Minecraft minecraft = gameRenderer.getMinecraft(); + ClientLevel currentLevel = minecraft.level; + if (currentLevel == null || minecraft.player == null) { + if (activeLevel != null || !FEEDS.isEmpty()) { + changeLevelNow(null); + } + return; + } + if (activeLevel != currentLevel) { + changeLevelNow(currentLevel); + } + + ProjectorBlockEntityRenderer.prepareNearby(minecraft); + if (minecraft.screen instanceof ChannelScreen channelScreen) { + channelScreen.preparePreview(); + } + RenderTarget mainTarget = minecraft.getMainRenderTarget(); + float partialTick = deltaTracker.getGameTimeDeltaPartialTick(true); + Camera viewerCamera = gameRenderer.getMainCamera(); + Vec3 viewerPosition = viewerCamera.position(); + Matrix4f mainProjection = new Matrix4f(gameRenderer.getGameRenderState().levelRenderState.cameraRenderState.projectionMatrix); + Matrix4f mainModelView = new Matrix4f(RenderSystem.getModelViewMatrix()); + boolean initializedFeedThisFrame = false; + for (ProjectionFeed feed : List.copyOf(FEEDS.values())) { + if (FEEDS.get(feed.key) != feed) { + continue; + } + feed.side.update(viewerPosition, frameSequence); + long requestAge = frameSequence - feed.lastRequestFrame; + long retention = feed.key.preview() ? PREVIEW_RETENTION_NANOS : FEED_RETENTION_NANOS; + if (feed.lastRequestFrame < 0L || System.nanoTime() - feed.lastRequestNanos > retention) { + FEEDS.remove(feed.key); + closeFeed(feed); + continue; + } + if (requestAge > 0L) { + feed.stage = ProjectionStage.RETAINED; + if (feed.remoteScene != null && feed.remoteScene.isTerminalFailure()) { + releaseRemoteFeed(feed); + } + continue; + } + if (feed.remoteScene != null && feed.remoteScene.isTerminalFailure()) { + feed.stage = ProjectionStage.SUBSCRIPTION_FAILED; + releaseRemoteFeed(feed); + feed.nextRetryFrame = frameSequence + FAILURE_RETRY_FRAMES; + continue; + } + if (frameSequence < feed.nextRetryFrame) { + continue; + } + + try { + PortalView portalView = configurePortalCamera( + feed, + viewerCamera, + mainProjection, + mainTarget + ); + ChunkPos cameraCenter = viewCenterChunk(feed); + int requestedRadius = ProjectionRenderContext.feedRenderDistance( + minecraft.options.getEffectiveRenderDistance() + ); + RemoteSceneHandle remoteScene = ensureRemoteScene(feed, cameraCenter, requestedRadius); + ClientLevel remoteLevel = remoteScene.level(); + int grantedRadius = remoteScene.grantedRadius(); + ClientLevel sceneLevel = remoteLevel; + ProjectionLightmap sceneLight = remoteScene.lightTexture(); + if (sceneLevel == null || grantedRadius < 1 || remoteScene.isUnavailable() + || (!feed.ready && !remoteScene.isReady(cameraCenter))) { + feed.stage = remoteLevel == null || grantedRadius < 1 ? ProjectionStage.WAITING_FOR_GRANT : ProjectionStage.WAITING_FOR_CAMERA_CHUNKS; + markUnavailable(feed, mainTarget); + continue; + } + applyCamera(feed.camera, sceneLevel, minecraft, portalView, partialTick); + boolean requiresInitialization = requiresResourceInitialization(feed, sceneLevel, grantedRadius); + if (requiresInitialization && initializedFeedThisFrame) { + feed.stage = ProjectionStage.WAITING_FOR_RENDERER_SLOT; + continue; + } + if (requiresInitialization) { + initializedFeedThisFrame = true; + } + long resourceStart = System.nanoTime(); + boolean resourcesReady = ensureResources( + minecraft, + sceneLevel, + sceneLight, + grantedRadius, + feed, + portalView + ); + reportSlowStage(feed, "resources", resourceStart); + if (!resourcesReady) { + feed.stage = ProjectionStage.INITIALIZING_RENDERER; + continue; + } + long prepareStart = System.nanoTime(); + prepareTerrain(feed, portalView); + reportSlowStage(feed, "terrain-preparation", prepareStart); + if (feed.ready && (feed.lastVisibleFrame < 0L || frameSequence - feed.lastVisibleFrame > REQUEST_RENDER_GRACE_FRAMES)) { + feed.stage = ProjectionStage.PRELOADED; + continue; + } + long drawStart = System.nanoTime(); + renderFeed(minecraft, deltaTracker, feed, portalView); + reportSlowStage(feed, "draw", drawStart); + feed.stage = feed.ready ? (feed.terrainReadyFrames < 2 ? ProjectionStage.REFRESHING_TERRAIN : ProjectionStage.READY) : ProjectionStage.WAITING_FOR_TERRAIN; + } catch (RuntimeException exception) { + LOGGER.error( + "[GLASS projector] render failed projector={} source={} stage={}; retrying in {} frames", + feed.key, + feed.source.key(), + feed.stage, + FAILURE_RETRY_FRAMES, + exception + ); + disposeFeedResources(feed); + feed.stage = ProjectionStage.RENDER_FAILED; + feed.nextRetryFrame = frameSequence + FAILURE_RETRY_FRAMES; + } finally { + restoreMainRenderState(mainTarget, gameRenderer, mainProjection, mainModelView); + } + } + restoreMainRenderState(mainTarget, gameRenderer, mainProjection, mainModelView); + ProjectorBlockEntityRenderer.prepareSurfaceBlending(currentLevel); + } + + public static void reset() { + RenderResources.runOnRenderThread(ProjectionRenderManager::resetNow); + } + + private static boolean requiresResourceInitialization(ProjectionFeed feed, ClientLevel level, int grantedRadius) { + return feed.renderer() == null + || feed.level() != level + || feed.rendererRadius() != grantedRadius + || feed.target == null + || feed.renderBuffers() == null + || !feed.terrain.key.equals(new TerrainKey(level, viewCenterChunk(feed), grantedRadius, feed.source.pos())); + } + + private static boolean chunkReady(ProjectionFeed feed, ChunkPos pos) { + return feed.remoteScene != null && feed.remoteScene.isReady(pos); + } + + private static void reportSlowStage(ProjectionFeed feed, String stage, long started) { + feed.trace.slowStage(stage, started, feed::diagnostics); + } + + private static RemoteSceneHandle ensureRemoteScene( + ProjectionFeed feed, + ChunkPos cameraCenter, + int requestedRadius + ) { + RemoteSceneHandle remoteScene = feed.remoteScene; + if (remoteScene == null) { + RemoteSubscriptionId subscription = new RemoteSubscriptionId( + feed.key.projectorDimension(), + feed.key.projectorPos(), + feed.key.preview(), + feed.source, + ++subscriptionSequence + ); + remoteScene = RemoteSceneClientManager.acquire(subscription, cameraCenter, requestedRadius); + feed.remoteScene = remoteScene; + } else { + RemoteSceneClientManager.update(remoteScene, cameraCenter, requestedRadius); + } + return remoteScene; + } + + private static void replaceRegistryNow(Map replacement) { + registrySources = replacement; + closeFeeds(feed -> !feed.source.equals(replacement.get(feed.source.channel()))); + } + + private static void chunkUnloadedNow(ClientLevel level, ChunkPos chunkPos) { + if (level != activeLevel || ProjectionChunkStorage.of(level).retained(chunkPos)) { + return; + } + for (ProjectionFeed feed : FEEDS.values()) { + if (feed.level() != level) { + continue; + } + ChunkPos cameraChunk = ChunkPos.containing(BlockPos.containing(feed.cameraPosition)); + resetUnloadedChunkSections(feed, chunkPos); + if (chunkPos.equals(ChunkPos.containing(feed.source.pos())) + || chunkPos.equals(cameraChunk)) { + feed.ready = false; + } + } + } + + private static void dirtyChunkSections(ProjectionFeed feed, ChunkPos chunkPos) { + ClientLevel level = feed.level(); + LevelRenderer renderer = feed.renderer(); + if (level == null || renderer == null) { + return; + } + if (!chunkInFeedView(feed, chunkPos.x(), chunkPos.z())) { + return; + } + ProjectionSections.markColumnDirty(renderer, level, chunkPos); + } + + private static void resetUnloadedChunkSections(ProjectionFeed feed, ChunkPos chunkPos) { + ClientLevel level = feed.level(); + LevelRenderer renderer = feed.renderer(); + if (level == null || renderer == null || !chunkInFeedView(feed, chunkPos.x(), chunkPos.z())) { + return; + } + ProjectionSections.resetColumn(renderer, level, chunkPos); + } + + private static boolean sectionInFeedView( + ProjectionFeed feed, + int sectionX, + int sectionY, + int sectionZ + ) { + ClientLevel level = feed.level(); + return level != null + && sectionY >= level.getMinSectionY() + && sectionY <= level.getMaxSectionY() + && chunkInFeedView(feed, sectionX, sectionZ); + } + + private static boolean chunkInFeedView(ProjectionFeed feed, int sectionX, int sectionZ) { + ChunkPos center = viewCenterChunk(feed); + int renderDistance = feedViewDistance(feed); + return new ProjectionChunkRegion(center, renderDistance).contains(sectionX, sectionZ); + } + + private static int feedViewDistance(ProjectionFeed feed) { + LevelRenderer renderer = feed.renderer(); + if (renderer != null) { + ViewArea viewArea = ((LevelRendererBufferAccessor) renderer).glass$getViewArea(); + if (viewArea != null) { + return viewArea.getViewDistance(); + } + } + int grantedRadius = feed.remoteScene == null + ? ProjectionRenderContext.feedRenderDistance(Minecraft.getInstance().options.getEffectiveRenderDistance()) + : feed.remoteScene.grantedRadius(); + return ProjectionRenderContext.feedRenderDistance( + Minecraft.getInstance().options.getEffectiveRenderDistance(), + grantedRadius + ); + } + + private static ChunkPos viewCenterChunk(ProjectionFeed feed) { + return feed.camera.retainGridCenter(feed.cameraPosition); + } + + private static void releaseProjectorNow(ResourceKey dimension, BlockPos projectorPos) { + closeFeeds(feed -> feed.key.projectorDimension().equals(dimension) && feed.key.projectorPos().equals(projectorPos)); + PORTAL_SIDES.remove(new ProjectionOwner(dimension, projectorPos)); + } + + private static void releasePreviewNow(ResourceKey dimension, BlockPos ownerPos) { + closeFeeds(feed -> feed.key.preview() && feed.key.projectorDimension().equals(dimension) && feed.key.projectorPos().equals(ownerPos)); + } + + private static void changeLevelNow(@Nullable ClientLevel level) { + closeFeeds(feed -> true); + PORTAL_SIDES.clear(); + activeLevel = level; + } + + private static void resetNow() { + changeLevelNow(null); + frameSequence = 0L; + } + + private static void closeFeeds(Predicate filter) { + List closed = FEEDS.values().stream().filter(filter).toList(); + for (ProjectionFeed feed : closed) { + FEEDS.remove(feed.key); + closeFeed(feed); + } + } + + private static boolean ensureResources( + Minecraft minecraft, + ClientLevel level, + @Nullable ProjectionLightmap lightTexture, + int grantedRadius, + ProjectionFeed feed, + PortalView portalView + ) { + if (lightTexture == null) { + return false; + } + TerrainKey terrainKey = new TerrainKey(level, viewCenterChunk(feed), grantedRadius, feed.source.pos()); + if (feed.terrain != null && feed.terrain.key.equals(terrainKey) && feed.target != null) { + resizeTarget(feed, portalView.targetWidth(), portalView.targetHeight()); + return true; + } + if (feed.terrain != null && feed.terrain.references == 1 && feed.level() == level + && feed.rendererRadius() == grantedRadius && !TERRAINS.containsKey(terrainKey)) { + TERRAINS.remove(feed.terrain.key, feed.terrain); + feed.terrain.key = terrainKey; + TERRAINS.put(terrainKey, feed.terrain); + resizeTarget(feed, portalView.targetWidth(), portalView.targetHeight()); + return true; + } + releaseTerrain(feed); + feed.ready = false; + feed.terrainReadyFrames = 0; + if (feed.target == null) { + feed.target = new TextureTarget("GLASS feed", portalView.targetWidth(), portalView.targetHeight(), true); + } else { + resizeTarget(feed, portalView.targetWidth(), portalView.targetHeight()); + } + TerrainResources terrain = TERRAINS.get(terrainKey); + boolean created = terrain == null; + if (created) { + terrain = new TerrainResources(terrainKey); + TERRAINS.put(terrainKey, terrain); + } + terrain.references++; + feed.terrain = terrain; + if (created) { + terrain.lightTexture = lightTexture; + terrain.buffers = new ProjectionRenderBuffers(FEED_BUILD_BUFFERS); + terrain.bufferCount = terrain.buffers.sectionBufferPool().getFreeBufferCount(); + terrain.renderer = new ProjectionLevelRenderer(minecraft, terrain.buffers, grantedRadius); + try (ProjectionRenderContext.Scope ignored = ProjectionRenderContext.enter( + terrain.renderer, feed.camera, feed.target, feed.source.pos(), level, lightTexture + )) { + terrain.renderer.setLevel(level); + } + } + if (feed.remoteScene != null && level == feed.remoteScene.level()) { + RemoteSceneClientManager.attachRenderer(feed.remoteScene, feed.renderer()); + } + if (feed.textureProxy == null) { + feed.textureProxy = new ProjectionTargetTexture(feed); + feed.textureManager = minecraft.getTextureManager(); + feed.textureManager.register(feed.textureLocation, feed.textureProxy); + } + return !created; + } + + private static void prepareTerrain(ProjectionFeed feed, PortalView portalView) { + Vec3 position = feed.camera.position(); + Matrix4f modelView = new Matrix4f().rotation(feed.camera.rotation().conjugate(new Quaternionf())); + Frustum frustum = new Frustum(modelView, portalView.projection()); + frustum.prepare(position.x, position.y, position.z); + try (ProjectionRenderContext.Scope ignored = ProjectionRenderContext.enter( + feed.renderer(), feed.camera, feed.target, feed.source.pos(), feed.level(), feed.lightTexture() + )) { + feed.renderer().prepareCamera(feed.camera); + LevelRendererBufferAccessor accessor = (LevelRendererBufferAccessor) feed.renderer(); + SectionRenderDispatcher dispatcher = feed.renderer().getSectionRenderDispatcher(); + uploadTerrain(dispatcher); + List visible = accessor.glass$getVisibleSections(); + visible.clear(); + for (SectionRenderDispatcher.RenderSection section : accessor.glass$getViewArea().sections) { + BlockPos origin = section.getRenderOrigin(); + if (!feed.level().getChunkSource().hasChunk(SectionPos.blockToSectionCoord(origin.getX()), SectionPos.blockToSectionCoord(origin.getZ()))) { + continue; + } + if (frustum.isVisible(section.getBoundingBox())) { + visible.add(section); + } + } + // Each schedule() submits exactly one runTask, and a runTask that finds the buffer pool empty + // re-queues its task without resubmitting, stranding it. Never queue more than there are free buffers. + if (feed.terrain.lastBuildFrame != frameSequence) { + feed.terrain.lastBuildFrame = frameSequence; + feed.terrain.scheduledBuilds = 0; + recoverStrandedBuilds(feed.terrain, dispatcher); + } + int buildSlots = Math.max(0, dispatcher.getFreeBufferCount() - dispatcher.getCompileQueueSize()); + buildSlots = Math.min(buildSlots, Math.max(0, feed.compileBufferCount() - feed.terrain.scheduledBuilds)); + Map chunkReadiness = new HashMap<>(); + List builds = new ArrayList<>(); + boolean visibleCompiled = true; + for (SectionRenderDispatcher.RenderSection section : visible) { + if (section.getSectionMesh() == net.minecraft.client.renderer.chunk.CompiledSectionMesh.UNCOMPILED) { + visibleCompiled = false; + } + if (buildSlots > 0 && canCompile(feed, section, chunkReadiness)) { + builds.add(section); + } + } + if (visibleCompiled && builds.isEmpty() && buildSlots > 0) { + for (SectionRenderDispatcher.RenderSection section : accessor.glass$getViewArea().sections) { + if (canCompile(feed, section, chunkReadiness)) { + builds.add(section); + } + } + } + if (!builds.isEmpty()) { + builds.sort(Comparator.comparing((SectionRenderDispatcher.RenderSection section) -> + section.getSectionMesh() != net.minecraft.client.renderer.chunk.CompiledSectionMesh.UNCOMPILED) + .thenComparingDouble(section -> section.getRenderOrigin().distToCenterSqr(position))); + RenderRegionCache regions = new RenderRegionCache(); + for (int i = 0; i < Math.min(buildSlots, builds.size()) && buildPreparationNanos < BUILD_PREPARATION_BUDGET_NANOS; i++) { + SectionRenderDispatcher.RenderSection section = builds.get(i); + long started = System.nanoTime(); + section.rebuildSectionAsync(regions); + section.setNotDirty(); + buildPreparationNanos += System.nanoTime() - started; + feed.terrain.scheduledBuilds++; + } + } + } + feed.terrainReadyFrames = terrainReady(feed) ? Math.min(2, feed.terrainReadyFrames + 1) : 0; + } + + private static void recoverStrandedBuilds(TerrainResources terrain, SectionRenderDispatcher dispatcher) { + boolean idleWithQueue = dispatcher.getCompileQueueSize() > 0 && dispatcher.getFreeBufferCount() >= terrain.bufferCount; + terrain.strandedFrames = idleWithQueue ? terrain.strandedFrames + 1 : 0; + if (terrain.strandedFrames >= STRANDED_BUILD_FRAMES) { + // Cancelling marks the sections dirty again, so they are rescheduled on the next frame. + dispatcher.clearCompileQueue(); + terrain.strandedFrames = 0; + } + } + + private static void uploadTerrain(SectionRenderDispatcher dispatcher) { + long started = System.nanoTime(); + dispatcher.lock(); + try { + dispatcher.uploadGlobalGeomBuffersToGPU(); + } finally { + dispatcher.unlock(); + uploadNanos += System.nanoTime() - started; + } + } + + private static boolean canCompile(ProjectionFeed feed, SectionRenderDispatcher.RenderSection section, Map chunkReadiness) { + BlockPos origin = section.getRenderOrigin(); + return section.isDirty() + && chunkReadiness.computeIfAbsent(ChunkPos.pack(SectionPos.blockToSectionCoord(origin.getX()), SectionPos.blockToSectionCoord(origin.getZ())), + packed -> chunkReady(feed, ChunkPos.unpack(packed))) + && section.hasAllNeighbors() + && feed.level().getLightEngine().lightOnInColumn(SectionPos.getZeroNode(SectionPos.asLong(origin))); + } + + private static boolean terrainReady(ProjectionFeed feed) { + if (!feed.remoteScene.isComplete()) { + return false; + } + List sections = ((LevelRendererBufferAccessor) feed.renderer()).glass$getVisibleSections(); + if (sections.isEmpty()) { + return false; + } + for (SectionRenderDispatcher.RenderSection section : sections) { + if (section.getSectionMesh() == net.minecraft.client.renderer.chunk.CompiledSectionMesh.UNCOMPILED) { + return false; + } + } + return true; + } + + private static void renderFeed(Minecraft minecraft, DeltaTracker deltaTracker, ProjectionFeed feed, PortalView portalView) { + if (feed.level() == null || feed.renderer() == null || feed.target == null || feed.lightTexture() == null + || (!feed.ready && feed.terrainReadyFrames < 2)) { + return; + } + try (ProjectionRenderContext.Scope ignored = ProjectionRenderContext.enter(feed.renderer(), feed.camera, + feed.target, feed.source.pos(), feed.level(), feed.lightTexture())) { + feed.lightTexture().update(feed.camera, deltaTracker.getGameTimeDeltaPartialTick(false)); + feed.renderer().renderScene(feed.camera, new Matrix4f(portalView.projection()), deltaTracker); + feed.ready = true; + feed.available = true; + feed.nextRetryFrame = 0L; + } + } + + + + + + private static PortalView configurePortalCamera( + ProjectionFeed feed, + Camera viewerCamera, + Matrix4f mainProjection, + RenderTarget mainTarget + ) { + if (feed.previewAspect > 0) { + Direction facing = feed.source.facing().getAxis().isHorizontal() ? feed.source.facing().getOpposite() : Direction.SOUTH; + Vec3 look = directionVector(facing); + Vec3 up = directionVector(Direction.UP); + Vec3 position = cameraPosition(feed.source); + double elapsedSeconds = (System.nanoTime() - feed.previewStartedNanos) / 1_000_000_000.0; + float yaw = (float) (elapsedSeconds * Math.PI / 60.0 % (Math.PI * 2)); + Quaternionf rotation = new Quaternionf().rotationY(yaw) + .mul(frameRotation(look.cross(up), up, look.scale(-1))) + .rotateX((float) Math.toRadians(-10)); + Matrix4f projection = new Matrix4f().perspective((float) Math.toRadians(85), feed.previewAspect, 0.05F, 1024.0F); + PortalView preview = new PortalView(position, rotation, projection, 512, Math.max(1, (int) (512 / feed.previewAspect))); + feed.camera.setPose(position, rotation); + feed.camera.setProjection(projection); + feed.cameraPosition = position; + return preview; + } + ProjectionView view = feed.alignment == null ? feed.view : feed.alignment.anchor; + PortalSide portalSide = feed.alignment == null ? feed.side : feed.alignment.side; + Vec3 surfaceNormal = view.normal(); + Vec3 viewerOffset = viewerCamera.position().subtract(view.projectorAnchor()); + double side = portalSide.resolve(view, viewerCamera.position()); + Vec3 sourceRight = directionVector(view.uDirection()).scale(side); + Vec3 sourceUp = directionVector(view.vDirection()); + Vec3 sourceBack = surfaceNormal.scale(side); + + Vec3 destinationLook = directionVector(feed.source.facing().getOpposite()); + Vec3 destinationUp = feed.source.facing().getAxis().isHorizontal() + ? directionVector(Direction.UP) + : directionVector(Direction.NORTH); + Vec3 destinationRight = destinationLook.cross(destinationUp); + Vec3 destinationBack = destinationLook.scale(-1.0D); + + Quaternionf sourceFrame = frameRotation(sourceRight, sourceUp, sourceBack); + Quaternionf destinationFrame = frameRotation(destinationRight, destinationUp, destinationBack); + Quaternionf portalRotation = new Quaternionf(destinationFrame) + .mul(new Quaternionf(sourceFrame).conjugate()); + Quaternionf cameraRotation = portalRotation + .mul(new Quaternionf(viewerCamera.rotation())) + .normalize(); + + double horizontalOffset = viewerOffset.dot(sourceRight); + double verticalOffset = viewerOffset.dot(sourceUp); + double normalOffset = viewerOffset.dot(sourceBack); + Vec3 destinationAnchor = cameraPosition(feed.source); + Vec3 dynamicPosition = destinationAnchor + .add(destinationRight.scale(horizontalOffset)) + .add(destinationUp.scale(verticalOffset)) + .add(destinationBack.scale(normalOffset)); + TargetSize targetSize = targetSize(mainTarget); + PortalView portalView = new PortalView( + dynamicPosition, + cameraRotation, + mainProjection, + targetSize.width(), + targetSize.height() + ); + feed.camera.setPose(portalView.cameraPosition(), portalView.cameraRotation()); + feed.camera.setProjection(portalView.projection()); + feed.cameraPosition = dynamicPosition; + return portalView; + } + + private static void applyCamera( + ProjectionCamera camera, + ClientLevel level, + Minecraft minecraft, + PortalView portalView, + float partialTick + ) { + camera.setLevel(level); + camera.attributeProbe().tick(level, portalView.cameraPosition()); + camera.setEntity(minecraft.player); + camera.setPose(portalView.cameraPosition(), portalView.cameraRotation()); + camera.setProjection(portalView.projection()); + } + + private static Quaternionf frameRotation(Vec3 right, Vec3 up, Vec3 back) { + return new Quaternionf().setFromNormalized(new Matrix3f( + (float) right.x, (float) right.y, (float) right.z, + (float) up.x, (float) up.y, (float) up.z, + (float) back.x, (float) back.y, (float) back.z + )); + } + + private static Vec3 directionVector(Direction direction) { + return new Vec3(direction.getStepX(), direction.getStepY(), direction.getStepZ()); + } + + private static PortalSide portalSide(ProjectionView view) { + ProjectionOwner owner = new ProjectionOwner(view.dimension(), view.projectorPos()); + return PORTAL_SIDES.computeIfAbsent(owner, ignored -> new PortalSide()); + } + + private static TargetSize targetSize(RenderTarget mainTarget) { + return new TargetSize( + Math.max(1, mainTarget.width), + Math.max(1, mainTarget.height) + ); + } + + private static void resizeTarget(ProjectionFeed feed, int width, int height) { + TextureTarget target = feed.target; + if (target == null || target.width == width && target.height == height) { + return; + } + target.resize(width, height); + feed.ready = false; + } + + private static void markUnavailable(ProjectionFeed feed, RenderTarget mainTarget) { + boolean clear = feed.ready || feed.available; + feed.ready = false; + feed.available = false; + if (clear && feed.target != null) { + RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures( + feed.target.getColorTexture(), 0xFF000000, feed.target.getDepthTexture(), 1.0); + } + } + + + + + + private static void restoreMainRenderState(RenderTarget mainTarget, GameRenderer gameRenderer, + Matrix4f mainProjection, Matrix4f mainModelView) { + if (ProjectionRenderContext.isActive()) { + throw new IllegalStateException("Projection render context escaped its scope"); + } + RenderSystem.getModelViewStack().set(mainModelView); + } + + + + private static void closeFeed(ProjectionFeed feed) { + long started = System.nanoTime(); + releaseRemoteFeed(feed); + reportSlowStage(feed, "release", started); + } + + private static void releaseRemoteFeed(ProjectionFeed feed) { + disposeFeedResources(feed); + RemoteSceneHandle remoteScene = feed.remoteScene; + feed.remoteScene = null; + if (remoteScene != null) { + RemoteSceneClientManager.release(remoteScene); + } + } + + private static void disposeFeedResources(ProjectionFeed feed) { + feed.ready = false; + feed.terrainReadyFrames = 0; + feed.available = false; + + TextureManager textureManager = feed.textureManager; + feed.textureManager = null; + ProjectionTargetTexture textureProxy = feed.textureProxy; + feed.textureProxy = null; + if (textureManager != null && textureProxy != null) { + cleanup(feed, "texture registration", () -> textureManager.release(feed.textureLocation)); + cleanup(feed, "texture proxy", textureProxy::close); + } + + releaseTerrain(feed); + + TextureTarget target = feed.target; + feed.target = null; + if (target != null) { + cleanup(feed, "render target", target::destroyBuffers); + } + feed.camera.reset(); + } + + private static void releaseTerrain(ProjectionFeed feed) { + TerrainResources terrain = feed.terrain; + if (feed.renderer() != null && feed.remoteScene != null && feed.level() == feed.remoteScene.level()) { + cleanup(feed, "remote renderer attachment", () -> RemoteSceneClientManager.detachRenderer(feed.remoteScene, feed.renderer())); + } + feed.terrain = null; + if (terrain == null || --terrain.references > 0) { + return; + } + TERRAINS.remove(terrain.key, terrain); + if (terrain.renderer != null) { + cleanup(feed, "renderer level", () -> terrain.renderer.setLevel(null)); + cleanup(feed, "level renderer", terrain.renderer::close); + ClientLevel currentLevel = Minecraft.getInstance().level; + if (currentLevel != null) { + } + } + if (terrain.buffers != null) { + cleanup(feed, "render buffers", () -> retireRenderBuffers(terrain.buffers, terrain.bufferCount)); + } + if (terrain.sortBuffer != null) { + cleanup(feed, "transparency sort buffer", terrain.sortBuffer::close); + } + } + + private static void cleanup(ProjectionFeed feed, String resource, Runnable operation) { + try { + operation.run(); + } catch (RuntimeException exception) { + LOGGER.warn("Failed to release projection feed {} {}", feed.source.key(), resource, exception); + } + } + + private static void retireRenderBuffers(RenderBuffers renderBuffers, int bufferCount) { + RenderResources.closeBufferBuilders(renderBuffers); + SectionBufferBuilderPool pool = renderBuffers.sectionBufferPool(); + if (pool.getFreeBufferCount() >= bufferCount) { + RenderResources.closeAvailablePoolBuffers(pool); + } else { + RETIRED_BUFFER_POOLS.add(new RetiredBufferPool(pool, bufferCount)); + } + } + + public static void drainRetiredBufferPools() { + RenderSystem.assertOnRenderThread(); + Iterator iterator = RETIRED_BUFFER_POOLS.iterator(); + while (iterator.hasNext()) { + RetiredBufferPool retired = iterator.next(); + if (retired.pool().getFreeBufferCount() >= retired.bufferCount()) { + RenderResources.closeAvailablePoolBuffers(retired.pool()); + iterator.remove(); + } + } + } + + private static Identifier nextTextureLocation() { + long sequence = ++textureSequence; + return Identifier.fromNamespaceAndPath( + "glass", + "projection/feed/" + Long.toUnsignedString(sequence, 36) + ); + } + + private record RetiredBufferPool(SectionBufferBuilderPool pool, int bufferCount) { + } + + private record TerrainKey(ClientLevel level, ChunkPos center, int radius, BlockPos hiddenBlock) { + } + + private static final class TerrainResources { + private TerrainKey key; + private ProjectionLevelRenderer renderer; + private RenderBuffers buffers; + private ProjectionLightmap lightTexture; + private ByteBufferBuilder sortBuffer; + private int bufferCount; + private int references; + private final long createdNanos = System.nanoTime(); + private long lastBuildFrame = -1L; + private int scheduledBuilds; + private int strandedFrames; + + private TerrainResources(TerrainKey key) { + this.key = key; + } + } + + private static Vec3 cameraPosition(ProjectionSource source) { + Direction facing = source.facing().getOpposite(); + return Vec3.atCenterOf(source.pos()).add( + facing.getStepX() * CAMERA_FACE_OFFSET, + facing.getStepY() * CAMERA_FACE_OFFSET, + facing.getStepZ() * CAMERA_FACE_OFFSET + ); + } + + private record FeedKey( + ProjectionSource.Key source, + ResourceKey projectorDimension, + BlockPos projectorPos, + boolean preview + ) { + private FeedKey { + projectorPos = projectorPos.immutable(); + } + } + + private record ProjectionOwner(ResourceKey dimension, BlockPos projectorPos) { + private ProjectionOwner { + projectorPos = projectorPos.immutable(); + } + } + + private record TargetSize(int width, int height) { + } + + private record PortalView( + Vec3 cameraPosition, + Quaternionf cameraRotation, + Matrix4f projection, + int targetWidth, + int targetHeight + ) { + private PortalView { + cameraRotation = new Quaternionf(cameraRotation); + projection = new Matrix4f(projection); + } + } + + private record SurfaceFaceKey(BlockPos position, Direction normal) { + } + + private static final class ProjectionAlignment { + private final ProjectionView anchor; + private final PortalSide side = new PortalSide(); + private final long createdFrame = frameSequence; + + private ProjectionAlignment(ProjectionView anchor) { + this.anchor = anchor; + } + } + + public static final class ProjectionFeed { + private final FeedKey key; + private final ProjectionSource source; + private final Identifier textureLocation; + private final ProjectionCamera camera = new ProjectionCamera(); + private final PortalSide side; + private final FeedTrace trace = new FeedTrace(); + private ProjectionView view; + private ProjectionAlignment alignment; + private Vec3 cameraPosition; + private long lastRequestFrame = -1L; + private long lastRequestNanos; + private long lastVisibleFrame = -1L; + private long nextRetryFrame; + private ProjectionStage stage = ProjectionStage.REQUESTED; + private boolean ready; + private float previewAspect; + private final long previewStartedNanos = System.nanoTime(); + private int terrainReadyFrames; + private boolean available; + private RemoteSceneHandle remoteScene; + private TerrainResources terrain; + private TextureTarget target; + private TextureManager textureManager; + private ProjectionTargetTexture textureProxy; + + private ProjectionFeed( + FeedKey key, + ProjectionSource source, + ProjectionView view, + PortalSide side, + Identifier textureLocation + ) { + this.key = key; + this.source = source; + this.view = view; + this.side = side; + this.textureLocation = textureLocation; + this.cameraPosition = ProjectionRenderManager.cameraPosition(source); + } + + public ProjectionSource source() { + return source; + } + + @Nullable + private ProjectionLevelRenderer renderer() { + return terrain == null ? null : terrain.renderer; + } + + @Nullable + private ClientLevel level() { + return terrain == null ? null : terrain.key.level(); + } + + private int rendererRadius() { + return terrain == null ? 0 : terrain.key.radius(); + } + + @Nullable + private ProjectionLightmap lightTexture() { + return terrain == null ? null : terrain.lightTexture; + } + + @Nullable + private RenderBuffers renderBuffers() { + return terrain == null ? null : terrain.buffers; + } + + private int compileBufferCount() { + return terrain == null ? 0 : terrain.bufferCount; + } + + public Identifier textureLocation() { + return textureLocation; + } + + public GpuTextureView colorTextureView() { + return target == null ? null : target.getColorTextureView(); + } + + public int colorTextureId() { + TextureTarget currentTarget = target; + return currentTarget == null || currentTarget.getColorTexture() == null ? 0 : System.identityHashCode(currentTarget.getColorTexture()); + } + + public boolean isReady() { + return ProjectionRenderManager.isReady(this); + } + + public boolean isFailed() { + return stage == ProjectionStage.RENDER_FAILED; + } + + public ProjectionStage stage() { + return stage; + } + + public FeedDiagnostics diagnostics() { + return new FeedDiagnostics( + key.projectorPos(), + source, + cameraPosition, + level() == null ? "none" : level() == activeLevel ? "local" : "remote", + lastRequestFrame < 0L ? -1L : frameSequence - lastRequestFrame, + lastVisibleFrame < 0L ? -1L : frameSequence - lastVisibleFrame, + Math.max(0L, nextRetryFrame - frameSequence), + terrainReadyFrames, + FeedDiagnostics.SectionStats.of(renderer(), pos -> chunkReady(this, pos)), + compileBufferCount(), + terrain == null ? 0 : terrain.references, + terrain == null ? 0L : (System.nanoTime() - terrain.createdNanos) / 1_000_000L, + colorTextureId(), + rendererRadius(), + remoteScene == null ? "none" : String.valueOf(remoteScene.diagnostics()) + ); + } + } + + private static final class ProjectionTargetTexture extends AbstractTexture { + private ProjectionFeed feed; + + private ProjectionTargetTexture(ProjectionFeed feed) { + this.feed = feed; + } + + @Override + public GpuTexture getTexture() { + return feed == null || feed.target == null ? null : feed.target.getColorTexture(); + } + + @Override + public GpuTextureView getTextureView() { + return feed == null || feed.target == null ? null : feed.target.getColorTextureView(); + } + + @Override + public void close() { + feed = null; + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderRegion.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderRegion.java new file mode 100644 index 0000000..ca5485e --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionRenderRegion.java @@ -0,0 +1,7 @@ +package dev.imb11.client.renderer.projection; + +import net.minecraft.core.BlockPos; + +public interface ProjectionRenderRegion { + void glass$setHiddenTerrainBlock(BlockPos position); +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionSections.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionSections.java new file mode 100644 index 0000000..a7f6581 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionSections.java @@ -0,0 +1,56 @@ +package dev.imb11.client.renderer.projection; + +import dev.imb11.mixins.LevelRendererBufferAccessor; +import dev.imb11.mixins.ViewAreaInvoker; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.ViewArea; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import org.jetbrains.annotations.Nullable; + +public final class ProjectionSections { + private ProjectionSections() { + } + + public static @Nullable SectionRenderDispatcher.RenderSection find(LevelRenderer renderer, int x, int y, int z) { + ViewArea area = ((LevelRendererBufferAccessor) renderer).glass$getViewArea(); + if (area == null) { + return null; + } + BlockPos origin = SectionPos.of(x, y, z).origin(); + SectionRenderDispatcher.RenderSection section = ((ViewAreaInvoker) area).glass$getRenderSectionAt(origin); + return section != null && section.getRenderOrigin().equals(origin) ? section : null; + } + + public static void markDirty(LevelRenderer renderer, int x, int y, int z) { + SectionRenderDispatcher.RenderSection section = find(renderer, x, y, z); + if (section != null) { + section.setDirty(false); + } + } + + public static void markColumnDirty(LevelRenderer renderer, ClientLevel level, ChunkPos chunk) { + for (int y = level.getMinSectionY(); y <= level.getMaxSectionY(); y++) { + markDirty(renderer, chunk.x(), y, chunk.z()); + } + } + + public static void resetColumn(LevelRenderer renderer, ClientLevel level, ChunkPos chunk) { + boolean reset = false; + for (int y = level.getMinSectionY(); y <= level.getMaxSectionY(); y++) { + SectionRenderDispatcher.RenderSection section = find(renderer, chunk.x(), y, chunk.z()); + if (section != null) { + BlockPos origin = section.getRenderOrigin(); + section.reset(); + section.setDirty(false); + reset = true; + } + } + if (reset) { + renderer.needsUpdate(); + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionStage.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionStage.java new file mode 100644 index 0000000..4c99c6b --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionStage.java @@ -0,0 +1,30 @@ +package dev.imb11.client.renderer.projection; + +import java.util.Locale; + +public enum ProjectionStage { + REQUESTED, + SURFACE_CHANGED, + RETAINED, + SUBSCRIPTION_FAILED, + WAITING_FOR_GRANT, + WAITING_FOR_CAMERA_CHUNKS, + WAITING_FOR_RENDERER_SLOT, + INITIALIZING_RENDERER, + PRELOADED, + WAITING_FOR_TERRAIN, + REFRESHING_TERRAIN, + READY, + RENDER_FAILED, + MISSING_CHANNEL_SOURCE, + OUTSIDE_PRELOAD_RANGE, + MISSING_SURFACE, + OUTSIDE_SURFACE_RANGE, + FEED_REQUEST_REJECTED, + SUBSCRIPTION_LIMIT; + + @Override + public String toString() { + return name().toLowerCase(Locale.ROOT).replace('_', '-'); + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionSurfaceRenderer.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionSurfaceRenderer.java new file mode 100644 index 0000000..9e011ac --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionSurfaceRenderer.java @@ -0,0 +1,473 @@ +package dev.imb11.client.renderer.projection; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.Tesselator; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.imb11.projection.ProjectionSurface; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Nullable; +import org.joml.Matrix4f; +import com.mojang.blaze3d.buffers.GpuBuffer; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.ColorTargetState; +import com.mojang.blaze3d.pipeline.DepthStencilState; +import com.mojang.blaze3d.platform.CompareOp; +import com.mojang.blaze3d.shaders.UniformType; +import com.mojang.blaze3d.textures.GpuTextureView; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.TextureAtlas; +import org.lwjgl.system.MemoryStack; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +public final class ProjectionSurfaceRenderer { + private static final float SURFACE_OFFSET = 0.002F; + private static final int MAX_PROJECTIONS = 4; + private static final int DISTANCE_RADIX = 4096; + private static final float CROSSFADE_WIDTH = 4.0F; + private static final String[] PROJECTION_SAMPLERS = {"Projection0", "Projection1", "Projection2", "Projection3"}; + private static final Identifier SHADER_LOCATION = + Identifier.fromNamespaceAndPath("glass", "core/projection_surface"); + private static final Map> CACHED_MESHES = new IdentityHashMap<>(); + private static ClientLevel activeLevel; + private static List projections = List.of(); + private static final Map SHARED_FACES = new HashMap<>(); + private static final Map> SHARED_EDGE_NORMALS = new HashMap<>(); + private static long layoutVersion; + private static final RenderPipeline SURFACE_PIPELINE = pipeline(false); + private static final RenderPipeline LOADING_PIPELINE = pipeline(true); + + private ProjectionSurfaceRenderer() { + } + + private static RenderPipeline pipeline(boolean loading) { + var builder = RenderPipeline.builder() + .withLocation(Identifier.fromNamespaceAndPath("glass", loading ? "projection_loading" : "projection_surface")) + .withVertexShader(SHADER_LOCATION).withFragmentShader(SHADER_LOCATION) + .withVertexFormat(DefaultVertexFormat.POSITION_TEX_COLOR, VertexFormat.Mode.QUADS) + .withUniform("DynamicTransforms", UniformType.UNIFORM_BUFFER) + .withUniform("Projection", UniformType.UNIFORM_BUFFER) + .withUniform("SurfaceParameters", UniformType.UNIFORM_BUFFER) + .withDepthStencilState(new DepthStencilState(CompareOp.LESS_THAN_OR_EQUAL, !loading)) + .withCull(false); + for (String sampler : PROJECTION_SAMPLERS) { + builder.withSampler(sampler); + } + if (loading) { + builder.withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)); + } + return builder.build(); + } + + public static void prepare(ClientLevel level, List visibleProjections) { + RenderSystem.assertOnRenderThread(); + if (activeLevel != level) { + resetNow(); + activeLevel = level; + } + List next = visibleProjections.stream() + .sorted(Comparator.comparingLong(projection -> projection.projectorPos().asLong())) + .limit(MAX_PROJECTIONS) + .toList(); + boolean changed = projections.size() != next.size(); + for (int index = 0; !changed && index < next.size(); index++) { + Projection previous = projections.get(index); + Projection replacement = next.get(index); + changed = !previous.projectorPos().equals(replacement.projectorPos()) + || !previous.surface().equals(replacement.surface()); + } + projections = next; + if (!changed) { + return; + } + + layoutVersion++; + SHARED_FACES.clear(); + SHARED_EDGE_NORMALS.clear(); + for (int index = 0; index < projections.size(); index++) { + for (ProjectionSurface.Face face : projections.get(index).surface().faces()) { + FaceKey key = new FaceKey(face.position(), face.normal()); + SharedFace shared = SHARED_FACES.get(key); + if (shared == null) { + shared = new SharedFace(index); + SHARED_FACES.put(key, shared); + addEdgeNormals(geometry(face), SHARED_EDGE_NORMALS); + } + if (face.revealDistance() < 0 || face.revealDistance() >= DISTANCE_RADIX) { + throw new IllegalArgumentException("Projection reveal distance is outside the 12-bit mesh range"); + } + shared.distances[index] = face.revealDistance(); + } + } + } + + public static void render(ClientLevel level, BlockPos projectorPos, ProjectionSurface surface, + @Nullable ProjectionRenderManager.ProjectionFeed feed, PoseStack matrices, float loadingOpacity) { + boolean loading = loadingOpacity >= 0.0F; + if (!loading && !ProjectionRenderManager.isReady(feed)) { + return; + } + CachedMesh mesh = getOrBuild(level, projectorPos, surface, loading); + if (mesh == null) { + return; + } + float[] reveals = {-1, -1, -1, -1}; + float[] channels = {0, 1, 2, 3}; + float[] positions = new float[16]; + Map textures = new HashMap<>(); + GpuTextureView fallback = feed == null ? null : feed.colorTextureView(); + if (fallback == null) { + fallback = Minecraft.getInstance().getTextureManager().getTexture(TextureAtlas.LOCATION_BLOCKS).getTextureView(); + } + for (int index = 0; index < MAX_PROJECTIONS; index++) { + Projection projection = index < projections.size() ? projections.get(index) : null; + textures.put(PROJECTION_SAMPLERS[index], projection == null ? fallback : projection.feed().colorTextureView()); + if (projection != null) { + for (int previous = 0; previous < index; previous++) { + if (projections.get(previous).feed().source().equals(projection.feed().source())) { + channels[index] = channels[previous]; + break; + } + } + if (!loading && (mesh.projectionMask & 1 << index) != 0) { + ProjectionRenderManager.markVisible(projection.feed()); + } + reveals[index] = projection.revealProgress(); + BlockPos offset = projection.surface().origin().subtract(mesh.origin); + Direction facing = projection.surface().facing(); + positions[index * 4] = offset.getX() + 0.5F + facing.getStepX() * 0.5F; + positions[index * 4 + 1] = offset.getY() + 0.5F + facing.getStepY() * 0.5F; + positions[index * 4 + 2] = offset.getZ() + 0.5F + facing.getStepZ() * 0.5F; + } + } + BlockPos offset = mesh.origin.subtract(projectorPos); + Matrix4f pose = new Matrix4f(matrices.last().pose()).translate(offset.getX(), offset.getY(), offset.getZ()); + try (MemoryStack stack = MemoryStack.stackPush()) { + var data = stack.malloc(112); + for (float value : reveals) data.putFloat(value); + for (float value : channels) data.putFloat(value); + for (float value : positions) data.putFloat(value); + data.putFloat(loadingOpacity).putFloat(CROSSFADE_WIDTH).putFloat(0).putFloat(0).flip(); + try (GpuBuffer parameters = RenderSystem.getDevice().createBuffer(() -> "GLASS surface parameters", GpuBuffer.USAGE_UNIFORM, data)) { + mesh.vertexBuffer.draw(loading ? LOADING_PIPELINE : SURFACE_PIPELINE, pose, textures, parameters); + } + } + } + + public static void release(ClientLevel level, BlockPos projectorPos) { + RenderResources.runOnRenderThread(() -> releaseNow(level, projectorPos)); + } + + public static void releaseLevel(ClientLevel level) { + RenderResources.runOnRenderThread(() -> releaseLevelNow(level)); + } + + public static void reset() { + RenderResources.runOnRenderThread(ProjectionSurfaceRenderer::resetNow); + } + + private static CachedMesh getOrBuild(ClientLevel level, BlockPos projectorPos, ProjectionSurface surface, boolean loading) { + if (activeLevel != level) { + resetNow(); + activeLevel = level; + } + Map levelMeshes = CACHED_MESHES.computeIfAbsent(level, ignored -> new HashMap<>()); + BlockPos key = projectorPos.immutable(); + CachedMesh cachedMesh = levelMeshes.get(key); + if (cachedMesh != null && cachedMesh.matches(surface, loading)) { + return cachedMesh.vertexBuffer == null ? null : cachedMesh; + } + + CachedMesh replacement = build(projectorPos, surface, loading); + levelMeshes.put(key, replacement); + if (cachedMesh != null) { + cachedMesh.close(); + } + return replacement.vertexBuffer == null ? null : replacement; + } + + private static CachedMesh build(BlockPos projectorPos, ProjectionSurface surface, boolean loading) { + List faceGeometries = new ArrayList<>(surface.faces().size()); + Map> edgeNormals = loading ? new HashMap<>() : SHARED_EDGE_NORMALS; + int projectionMask = 0; + for (ProjectionSurface.Face face : surface.faces()) { + FaceGeometry geometry = geometry(face); + SharedFace shared = SHARED_FACES.get(new FaceKey(face.position(), face.normal())); + if (loading) { + addEdgeNormals(geometry, edgeNormals); + } + if (loading || shared != null && projections.get(shared.owner).projectorPos().equals(projectorPos)) { + faceGeometries.add(geometry); + if (shared != null) { + for (int index = 0; index < MAX_PROJECTIONS; index++) { + if (shared.distances[index] >= 0) { + projectionMask |= 1 << index; + } + } + } + } + } + + if (faceGeometries.isEmpty()) { + return new CachedMesh(surface, loading, projectionMask, null); + } + BufferBuilder builder = Tesselator.getInstance().begin( + VertexFormat.Mode.QUADS, + DefaultVertexFormat.POSITION_TEX_COLOR + ); + for (FaceGeometry geometry : faceGeometries) { + emitFace(builder, surface.origin(), geometry, edgeNormals); + } + + ProjectionMesh vertexBuffer = new ProjectionMesh(builder.buildOrThrow()); + return new CachedMesh(surface, loading, projectionMask, vertexBuffer); + } + + private static void addEdgeNormals(FaceGeometry geometry, Map> edgeNormals) { + for (EdgeKey edge : geometry.edges()) { + edgeNormals.computeIfAbsent(edge, ignored -> EnumSet.noneOf(Direction.class)) + .add(geometry.face().normal()); + } + } + + private static FaceGeometry geometry(ProjectionSurface.Face face) { + BlockPos position = face.position(); + IntegerCorner[] corners = { + corner(position, face.normal(), face.uDirection(), face.vDirection(), false, false), + corner(position, face.normal(), face.uDirection(), face.vDirection(), true, false), + corner(position, face.normal(), face.uDirection(), face.vDirection(), true, true), + corner(position, face.normal(), face.uDirection(), face.vDirection(), false, true) + }; + EdgeKey[] edges = { + new EdgeKey(corners[0], corners[1]), + new EdgeKey(corners[1], corners[2]), + new EdgeKey(corners[2], corners[3]), + new EdgeKey(corners[3], corners[0]) + }; + return new FaceGeometry(face, corners, edges); + } + + private static IntegerCorner corner( + BlockPos position, + Direction normal, + Direction uDirection, + Direction vDirection, + boolean highU, + boolean highV + ) { + int x = position.getX() + Math.max(0, normal.getStepX()); + int y = position.getY() + Math.max(0, normal.getStepY()); + int z = position.getZ() + Math.max(0, normal.getStepZ()); + int uOffset = cornerOffset(uDirection, highU); + int vOffset = cornerOffset(vDirection, highV); + x += Math.abs(uDirection.getStepX()) * uOffset + Math.abs(vDirection.getStepX()) * vOffset; + y += Math.abs(uDirection.getStepY()) * uOffset + Math.abs(vDirection.getStepY()) * vOffset; + z += Math.abs(uDirection.getStepZ()) * uOffset + Math.abs(vDirection.getStepZ()) * vOffset; + return new IntegerCorner(x, y, z); + } + + private static void emitFace( + VertexConsumer vertices, + BlockPos origin, + FaceGeometry geometry, + Map> edgeNormals + ) { + ProjectionSurface.Face face = geometry.face(); + SharedFace shared = SHARED_FACES.get(new FaceKey(face.position(), face.normal())); + int[] distances = shared == null ? new int[]{-1, -1, -1, -1} : shared.distances; + float u = Math.max(0, distances[0]) + Math.max(0, distances[1]) * DISTANCE_RADIX; + float v = Math.max(0, distances[2]) + Math.max(0, distances[3]) * DISTANCE_RADIX; + for (int index = 0; index < geometry.corners().length; index++) { + EdgeKey previousEdge = geometry.edges()[(index + geometry.edges().length - 1) % geometry.edges().length]; + EdgeKey nextEdge = geometry.edges()[index]; + emitVertex( + vertices, + origin, + geometry.corners()[index], + face.normal(), + edgeNormals.get(previousEdge), + edgeNormals.get(nextEdge), + u, + v, + distances[0] >= 0 ? 255 : 0, + distances[1] >= 0 ? 255 : 0, + distances[2] >= 0 ? 255 : 0, + distances[3] >= 0 ? 255 : 0 + ); + } + } + + private static void emitVertex( + VertexConsumer vertices, + BlockPos origin, + IntegerCorner corner, + Direction normal, + EnumSet previousEdgeNormals, + EnumSet nextEdgeNormals, + float u, + float v, + int red, + int green, + int blue, + int alpha + ) { + EnumSet miterNormals = EnumSet.of(normal); + if (previousEdgeNormals != null) { + miterNormals.addAll(previousEdgeNormals); + } + if (nextEdgeNormals != null) { + miterNormals.addAll(nextEdgeNormals); + } + float x = corner.x() - origin.getX(); + float y = corner.y() - origin.getY(); + float z = corner.z() - origin.getZ(); + for (Direction miterNormal : miterNormals) { + x += miterNormal.getStepX() * SURFACE_OFFSET; + y += miterNormal.getStepY() * SURFACE_OFFSET; + z += miterNormal.getStepZ() * SURFACE_OFFSET; + } + vertices.addVertex(x, y, z) + .setUv(u, v) + .setColor(red, green, blue, alpha); + } + + private static int cornerOffset(Direction direction, boolean high) { + boolean positive = direction.getAxisDirection() == Direction.AxisDirection.POSITIVE; + return positive == high ? 1 : 0; + } + + private static void releaseNow(ClientLevel level, BlockPos projectorPos) { + Map levelMeshes = CACHED_MESHES.get(level); + if (levelMeshes == null) { + return; + } + CachedMesh cachedMesh = levelMeshes.remove(projectorPos); + if (cachedMesh != null) { + cachedMesh.close(); + } + if (levelMeshes.isEmpty()) { + CACHED_MESHES.remove(level); + } + } + + private static void releaseLevelNow(ClientLevel level) { + Map levelMeshes = CACHED_MESHES.remove(level); + if (levelMeshes != null) { + levelMeshes.values().forEach(CachedMesh::close); + } + if (activeLevel == level) { + activeLevel = null; + } + } + + private static void resetNow() { + CACHED_MESHES.values().forEach(levelMeshes -> levelMeshes.values().forEach(CachedMesh::close)); + CACHED_MESHES.clear(); + projections = List.of(); + SHARED_FACES.clear(); + SHARED_EDGE_NORMALS.clear(); + layoutVersion++; + activeLevel = null; + } + + private static final class CachedMesh implements AutoCloseable { + private final ProjectionSurface surface; + private final BlockPos origin; + private final long layoutVersion; + private final boolean loading; + private final int projectionMask; + private final ProjectionMesh vertexBuffer; + + private CachedMesh( + ProjectionSurface surface, + boolean loading, + int projectionMask, + ProjectionMesh vertexBuffer + ) { + this.surface = surface; + this.origin = surface.origin().immutable(); + this.layoutVersion = ProjectionSurfaceRenderer.layoutVersion; + this.loading = loading; + this.projectionMask = projectionMask; + this.vertexBuffer = vertexBuffer; + } + + private boolean matches(ProjectionSurface surface, boolean loading) { + return layoutVersion == ProjectionSurfaceRenderer.layoutVersion + && this.loading == loading + && this.surface.equals(surface); + } + + @Override + public void close() { + if (vertexBuffer != null) { + vertexBuffer.close(); + } + } + } + + public record Projection( + BlockPos projectorPos, + ProjectionSurface surface, + ProjectionRenderManager.ProjectionFeed feed, + float revealProgress + ) { + } + + private record FaceKey(BlockPos position, Direction normal) { + } + + private static final class SharedFace { + private final int owner; + private final int[] distances = new int[MAX_PROJECTIONS]; + + private SharedFace(int owner) { + this.owner = owner; + Arrays.fill(distances, -1); + } + } + + private record FaceGeometry( + ProjectionSurface.Face face, + IntegerCorner[] corners, + EdgeKey[] edges + ) { + } + + private record IntegerCorner(int x, int y, int z) implements Comparable { + @Override + public int compareTo(IntegerCorner other) { + int xComparison = Integer.compare(x, other.x); + if (xComparison != 0) { + return xComparison; + } + int yComparison = Integer.compare(y, other.y); + return yComparison != 0 ? yComparison : Integer.compare(z, other.z); + } + } + + private record EdgeKey(IntegerCorner first, IntegerCorner second) { + private EdgeKey { + if (first.compareTo(second) > 0) { + IntegerCorner swap = first; + first = second; + second = swap; + } + } + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionView.java b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionView.java new file mode 100644 index 0000000..08e68be --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/ProjectionView.java @@ -0,0 +1,46 @@ +package dev.imb11.client.renderer.projection; + +import dev.imb11.projection.ProjectionSurface; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +import java.util.List; + +record ProjectionView( + ResourceKey dimension, + BlockPos projectorPos, + BlockPos origin, + Direction facing, + Direction uDirection, + Direction vDirection, + List faces, + AABB bounds +) { + private static final double PROJECTOR_PLANE_OFFSET = 0.502D; + + ProjectionView { + projectorPos = projectorPos.immutable(); + origin = origin.immutable(); + } + + static ProjectionView create(ResourceKey dimension, BlockPos projectorPos, ProjectionSurface surface) { + return new ProjectionView(dimension, projectorPos, surface.origin(), surface.facing(), surface.uDirection(), + surface.vDirection(), surface.faces(), surface.renderBounds()); + } + + boolean sameFrame(ProjectionView other) { + return facing == other.facing && origin.equals(other.origin); + } + + Vec3 normal() { + return Vec3.atLowerCornerOf(facing.getUnitVec3i()); + } + + Vec3 projectorAnchor() { + return Vec3.atCenterOf(projectorPos).add(normal().scale(PROJECTOR_PLANE_OFFSET)); + } +} diff --git a/glass/src/main/java/dev/imb11/client/renderer/projection/RenderResources.java b/glass/src/main/java/dev/imb11/client/renderer/projection/RenderResources.java new file mode 100644 index 0000000..9d26159 --- /dev/null +++ b/glass/src/main/java/dev/imb11/client/renderer/projection/RenderResources.java @@ -0,0 +1,53 @@ +package dev.imb11.client.renderer.projection; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import dev.imb11.mixins.BufferSourceAccessor; +import dev.imb11.mixins.LevelRendererBufferAccessor; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderBuffers; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; +import net.minecraft.client.renderer.SectionBufferBuilderPack; +import net.minecraft.client.renderer.SectionBufferBuilderPool; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +public final class RenderResources { + private RenderResources() { + } + + public static void runOnRenderThread(Runnable operation) { + if (RenderSystem.isOnRenderThread()) { + operation.run(); + } else { + net.minecraft.client.Minecraft.getInstance().execute(operation); + } + } + + public static void closeBufferBuilders(RenderBuffers renderBuffers) { + Set builders = Collections.newSetFromMap(new IdentityHashMap<>()); + SectionBufferBuilderPack fixed = renderBuffers.fixedBufferPack(); + for (ChunkSectionLayer renderType : ChunkSectionLayer.values()) { + builders.add(fixed.buffer(renderType)); + } + addBuilders(builders, renderBuffers.bufferSource()); + addBuilders(builders, renderBuffers.crumblingBufferSource()); + builders.forEach(ByteBufferBuilder::close); + } + + public static void closeAvailablePoolBuffers(SectionBufferBuilderPool pool) { + SectionBufferBuilderPack pack; + while ((pack = pool.acquire()) != null) { + pack.close(); + } + } + + private static void addBuilders(Set builders, MultiBufferSource.BufferSource source) { + BufferSourceAccessor accessor = (BufferSourceAccessor) source; + builders.add(accessor.glass$getSharedBuffer()); + builders.addAll(accessor.glass$getFixedBuffers().values()); + } +} diff --git a/glass/src/main/java/dev/imb11/datagen/GlassBlockLootTableProvider.java b/glass/src/main/java/dev/imb11/datagen/GlassBlockLootTableProvider.java new file mode 100644 index 0000000..9e72378 --- /dev/null +++ b/glass/src/main/java/dev/imb11/datagen/GlassBlockLootTableProvider.java @@ -0,0 +1,24 @@ +//? fabric { +package dev.imb11.datagen; + +import dev.imb11.blocks.GBlocks; +import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput; +import net.fabricmc.fabric.api.datagen.v1.provider.FabricBlockLootSubProvider; +import net.minecraft.core.HolderLookup; + +import java.util.concurrent.CompletableFuture; + +public class GlassBlockLootTableProvider extends FabricBlockLootSubProvider { + public GlassBlockLootTableProvider(FabricPackOutput output, CompletableFuture registriesFuture) { + super(output, registriesFuture); + } + + @Override + public void generate() { + dropSelf(GBlocks.REDSTONE_INFUSED_SAND); + dropWhenSilkTouch(GBlocks.POWERABLE_GLASS); + dropSelf(GBlocks.PROJECTOR); + dropSelf(GBlocks.TERMINAL); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/datagen/GlassBlockTagProvider.java b/glass/src/main/java/dev/imb11/datagen/GlassBlockTagProvider.java new file mode 100644 index 0000000..cd85513 --- /dev/null +++ b/glass/src/main/java/dev/imb11/datagen/GlassBlockTagProvider.java @@ -0,0 +1,25 @@ +//? fabric { +package dev.imb11.datagen; + +import dev.imb11.blocks.GBlocks; +import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput; +import net.fabricmc.fabric.api.datagen.v1.provider.FabricTagsProvider; +import net.minecraft.core.HolderLookup; +import net.minecraft.tags.BlockTags; + +import java.util.concurrent.CompletableFuture; + +public class GlassBlockTagProvider extends FabricTagsProvider.BlockTagsProvider { + public GlassBlockTagProvider(FabricPackOutput output, CompletableFuture registriesFuture) { + super(output, registriesFuture); + } + + @Override + protected void addTags(HolderLookup.Provider registries) { + builder(GBlocks.GLASS_BLOCKS).add(GBlocks.POWERABLE_GLASS.builtInRegistryHolder().key()); + builder(BlockTags.MINEABLE_WITH_SHOVEL).add(GBlocks.REDSTONE_INFUSED_SAND.builtInRegistryHolder().key()); + builder(BlockTags.MINEABLE_WITH_PICKAXE).add(GBlocks.PROJECTOR.builtInRegistryHolder().key(), GBlocks.TERMINAL.builtInRegistryHolder().key()); + builder(BlockTags.NEEDS_DIAMOND_TOOL).add(GBlocks.TERMINAL.builtInRegistryHolder().key()); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/datagen/GlassDataGenerator.java b/glass/src/main/java/dev/imb11/datagen/GlassDataGenerator.java new file mode 100644 index 0000000..4fbedd8 --- /dev/null +++ b/glass/src/main/java/dev/imb11/datagen/GlassDataGenerator.java @@ -0,0 +1,18 @@ +//? fabric { +package dev.imb11.datagen; + +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; + +public class GlassDataGenerator implements DataGeneratorEntrypoint { + @Override + public void onInitializeDataGenerator(FabricDataGenerator generator) { + FabricDataGenerator.Pack pack = generator.createPack(); + pack.addProvider(GlassRecipeProvider::new); + pack.addProvider(GlassBlockLootTableProvider::new); + pack.addProvider(GlassBlockTagProvider::new); + pack.addProvider(GlassModelProvider::new); + pack.addProvider(GlassLanguageProvider::new); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/datagen/GlassLanguageProvider.java b/glass/src/main/java/dev/imb11/datagen/GlassLanguageProvider.java new file mode 100644 index 0000000..faf7b79 --- /dev/null +++ b/glass/src/main/java/dev/imb11/datagen/GlassLanguageProvider.java @@ -0,0 +1,26 @@ +//? fabric { +package dev.imb11.datagen; + +import dev.imb11.blocks.GBlocks; +import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput; +import net.fabricmc.fabric.api.datagen.v1.provider.FabricLanguageProvider; +import net.minecraft.core.HolderLookup; + +import java.util.concurrent.CompletableFuture; + +public class GlassLanguageProvider extends FabricLanguageProvider { + public GlassLanguageProvider(FabricPackOutput output, CompletableFuture registriesFuture) { + super(output, "en_us", registriesFuture); + } + + @Override + public void generateTranslations(HolderLookup.Provider registries, TranslationBuilder translations) { + translations.add(GBlocks.REDSTONE_INFUSED_SAND, "Redstone Infused Sand"); + translations.add(GBlocks.POWERABLE_GLASS, "Powerable Glass"); + translations.add(GBlocks.PROJECTOR, "G.L.A.S.S Projector"); + translations.add(GBlocks.TERMINAL, "G.L.A.S.S Terminal"); + translations.add("subtitles.glass.projection.panel_activate", "Projection panel activates"); + translations.add("subtitles.glass.projection.panel_deactivate", "Projection panel deactivates"); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/datagen/GlassModelProvider.java b/glass/src/main/java/dev/imb11/datagen/GlassModelProvider.java new file mode 100644 index 0000000..8eb3097 --- /dev/null +++ b/glass/src/main/java/dev/imb11/datagen/GlassModelProvider.java @@ -0,0 +1,83 @@ +//? fabric { +package dev.imb11.datagen; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.ProjectorBlock; +import dev.imb11.blocks.TerminalBlock; +import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput; +import net.fabricmc.fabric.api.client.datagen.v1.provider.FabricModelProvider; +import net.minecraft.core.Direction; +import net.minecraft.client.data.models.BlockModelGenerators; +import net.minecraft.client.data.models.ItemModelGenerators; +import net.minecraft.client.data.models.blockstates.MultiVariantGenerator; +import net.minecraft.client.data.models.blockstates.PropertyDispatch; +import net.minecraft.client.renderer.block.dispatch.Variant; +import net.minecraft.client.renderer.block.dispatch.VariantMutator; +import net.minecraft.client.data.models.MultiVariant; +import net.minecraft.util.random.WeightedList; +import com.mojang.math.Quadrant; +import net.minecraft.client.data.models.model.ModelTemplate; +import net.minecraft.client.data.models.model.ModelTemplates; +import net.minecraft.client.data.models.model.TextureMapping; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Block; + +import java.util.Optional; + +public class GlassModelProvider extends FabricModelProvider { + public GlassModelProvider(FabricPackOutput output) { + super(output); + } + + @Override + public void generateBlockStateModels(BlockModelGenerators generator) { + createCube(generator, GBlocks.REDSTONE_INFUSED_SAND, GBlocks.REDSTONE_INFUSED_SAND); + createCube(generator, GBlocks.POWERABLE_GLASS, GBlocks.POWERABLE_GLASS); + + Identifier terminalModel = new ModelTemplate(Optional.of(Identifier.fromNamespaceAndPath("glass", "block/terminal_camera")), Optional.empty()) + .create(GBlocks.TERMINAL, new TextureMapping(), generator.modelOutput); + Identifier projectingTerminalModel = new ModelTemplate(Optional.of(terminalModel), Optional.of("_projecting")) + .create(GBlocks.TERMINAL, new TextureMapping(), generator.modelOutput); + generator.blockStateOutput.accept(MultiVariantGenerator.dispatch(GBlocks.TERMINAL) + .with(PropertyDispatch.initial(TerminalBlock.PROJECTING) + .select(false, plain(terminalModel)) + .select(true, plain(projectingTerminalModel))) + .with(PropertyDispatch.modify(TerminalBlock.FACING) + .select(Direction.SOUTH, BlockModelGenerators.NOP) + .select(Direction.NORTH, VariantMutator.Y_ROT.withValue(Quadrant.R180)) + .select(Direction.WEST, VariantMutator.Y_ROT.withValue(Quadrant.R90)) + .select(Direction.EAST, VariantMutator.Y_ROT.withValue(Quadrant.R270)) + .select(Direction.UP, VariantMutator.X_ROT.withValue(Quadrant.R90)) + .select(Direction.DOWN, VariantMutator.X_ROT.withValue(Quadrant.R270).then(VariantMutator.Y_ROT.withValue(Quadrant.R180))))); + + Identifier projectorModel = new ModelTemplate(Optional.of(Identifier.fromNamespaceAndPath("glass", "block/projector_assembly")), Optional.empty()) + .create(GBlocks.PROJECTOR, new TextureMapping(), generator.modelOutput); + generator.blockStateOutput.accept(MultiVariantGenerator.dispatch(GBlocks.PROJECTOR) + .with(PropertyDispatch.initial(ProjectorBlock.POWERED) + .select(false, plain(projectorModel)) + .select(true, plain(projectorModel))) + .with(PropertyDispatch.modify(ProjectorBlock.FACING) + .select(Direction.UP, BlockModelGenerators.NOP) + .select(Direction.DOWN, VariantMutator.X_ROT.withValue(Quadrant.R180)) + .select(Direction.SOUTH, VariantMutator.X_ROT.withValue(Quadrant.R90)) + .select(Direction.WEST, VariantMutator.X_ROT.withValue(Quadrant.R90).then(VariantMutator.Y_ROT.withValue(Quadrant.R90))) + .select(Direction.NORTH, VariantMutator.X_ROT.withValue(Quadrant.R90).then(VariantMutator.Y_ROT.withValue(Quadrant.R180))) + .select(Direction.EAST, VariantMutator.X_ROT.withValue(Quadrant.R90).then(VariantMutator.Y_ROT.withValue(Quadrant.R270))))); + generator.registerSimpleItemModel(GBlocks.PROJECTOR, Identifier.fromNamespaceAndPath("glass", "item/projector")); + generator.registerSimpleItemModel(GBlocks.TERMINAL, terminalModel); + } + + @Override + public void generateItemModels(ItemModelGenerators generator) { + } + + private static void createCube(BlockModelGenerators generator, Block block, Block texture) { + Identifier model = ModelTemplates.CUBE_ALL.create(block, TextureMapping.cube(texture), generator.modelOutput); + generator.blockStateOutput.accept(MultiVariantGenerator.dispatch(block, plain(model))); + generator.registerSimpleItemModel(block, model); + } + private static MultiVariant plain(Identifier model) { + return new MultiVariant(WeightedList.of(new Variant(model))); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/datagen/GlassRecipeProvider.java b/glass/src/main/java/dev/imb11/datagen/GlassRecipeProvider.java new file mode 100644 index 0000000..4d2a9b6 --- /dev/null +++ b/glass/src/main/java/dev/imb11/datagen/GlassRecipeProvider.java @@ -0,0 +1,69 @@ +//? fabric { +package dev.imb11.datagen; + +import dev.imb11.items.GItems; +import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput; +import net.fabricmc.fabric.api.datagen.v1.provider.FabricRecipeProvider; +import net.minecraft.core.HolderLookup; +import net.minecraft.data.recipes.RecipeCategory; +import net.minecraft.data.recipes.RecipeOutput; +import net.minecraft.data.recipes.RecipeProvider; +import net.minecraft.world.item.crafting.CookingBookCategory; +import net.minecraft.data.recipes.ShapedRecipeBuilder; +import net.minecraft.data.recipes.ShapelessRecipeBuilder; +import net.minecraft.data.recipes.SimpleCookingRecipeBuilder; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.crafting.Ingredient; + +import java.util.concurrent.CompletableFuture; + +public class GlassRecipeProvider extends FabricRecipeProvider { + public GlassRecipeProvider(FabricPackOutput output, CompletableFuture registriesFuture) { + super(output, registriesFuture); + } + + @Override + public String getName() { + return "GLASS recipes"; + } + + @Override + protected RecipeProvider createRecipeProvider(HolderLookup.Provider registries, RecipeOutput output) { + return new RecipeProvider(registries, output) { + @Override + public void buildRecipes() { + shapeless(RecipeCategory.BUILDING_BLOCKS, GItems.REDSTONE_INFUSED_SAND) + .requires(Items.REDSTONE) + .requires(Items.SAND) + .unlockedBy("has_ingredient", has(Items.REDSTONE)) + .save(output); + + SimpleCookingRecipeBuilder.smelting(Ingredient.of(GItems.REDSTONE_INFUSED_SAND), RecipeCategory.BUILDING_BLOCKS, CookingBookCategory.BLOCKS, GItems.POWERABLE_GLASS, 0.1F, 200) + .unlockedBy("has_ingredient", has(GItems.REDSTONE_INFUSED_SAND)) + .save(output); + + shaped(RecipeCategory.REDSTONE, GItems.PROJECTOR) + .pattern("GQG") + .pattern("GRG") + .pattern("OOO") + .define('G', GItems.POWERABLE_GLASS) + .define('Q', Items.QUARTZ) + .define('R', Items.REDSTONE) + .define('O', Items.OBSIDIAN) + .unlockedBy("has_ingredient", has(Items.QUARTZ)) + .save(output); + + shaped(RecipeCategory.REDSTONE, GItems.TERMINAL) + .pattern("OBO") + .pattern("OEO") + .pattern("OOO") + .define('O', Items.OBSIDIAN) + .define('B', Items.OBSERVER) + .define('E', Items.ENDER_EYE) + .unlockedBy("has_ingredient", has(Items.OBSERVER)) + .save(output); + } + }; + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/debug/FeedDiagnostics.java b/glass/src/main/java/dev/imb11/debug/FeedDiagnostics.java new file mode 100644 index 0000000..ff47dd0 --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/FeedDiagnostics.java @@ -0,0 +1,82 @@ +package dev.imb11.debug; + +import dev.imb11.mixins.LevelRendererBufferAccessor; +import dev.imb11.sync.ProjectionSource; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.phys.Vec3; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Predicate; + +public record FeedDiagnostics( + BlockPos projector, + ProjectionSource source, + Vec3 camera, + String terrainSource, + long requestAgeFrames, + long visibleAgeFrames, + long retryFrames, + int terrainReadyFrames, + SectionStats sections, + int buildBuffers, + int terrainUsers, + long terrainAgeMs, + int texture, + int rendererRadius, + String remote +) { + @Override + public String toString() { + return "projector=" + projector.toShortString() + " source=" + source + " camera=" + camera + " terrainSource=" + terrainSource + + " requestAgeFrames=" + requestAgeFrames + " visibleAgeFrames=" + visibleAgeFrames + + " retryFrames=" + retryFrames + " terrainReadyFrames=" + terrainReadyFrames + " " + sections + + " buildBuffers=" + buildBuffers + " terrainUsers=" + terrainUsers + " terrainAgeMs=" + terrainAgeMs + + " texture=" + texture + " rendererRadius=" + rendererRadius + + " remote={" + remote + "}"; + } + + public record SectionStats( + int visible, + int dirty, + int uncompiled, + @Nullable BlockPos firstBlocked, + boolean firstBlockedChunkReady, + boolean compileQueueEmpty, + String buildQueue + ) { + public static SectionStats of(@Nullable LevelRenderer renderer, Predicate chunkReady) { + if (renderer == null) { + return new SectionStats(0, 0, 0, null, false, false, "none"); + } + int visible = 0; + int dirty = 0; + int uncompiled = 0; + BlockPos firstBlocked = null; + for (SectionRenderDispatcher.RenderSection section : ((LevelRendererBufferAccessor) renderer).glass$getVisibleSections()) { + visible++; + if (section.isDirty()) { + dirty++; + } + if (section.getSectionMesh() == net.minecraft.client.renderer.chunk.CompiledSectionMesh.UNCOMPILED) { + uncompiled++; + if (firstBlocked == null) { + firstBlocked = section.getRenderOrigin().immutable(); + } + } + } + return new SectionStats(visible, dirty, uncompiled, firstBlocked, + firstBlocked != null && chunkReady.test(ChunkPos.containing(firstBlocked)), + renderer.hasRenderedAllSections(), renderer.getSectionStatistics()); + } + + @Override + public String toString() { + return "visibleSections=" + visible + " dirtySections=" + dirty + " uncompiledSections=" + uncompiled + + " firstBlockedSection=" + firstBlocked + " firstBlockedChunkReady=" + firstBlockedChunkReady + + " compileQueueEmpty=" + compileQueueEmpty + " buildQueue=" + buildQueue; + } + } +} diff --git a/glass/src/main/java/dev/imb11/debug/FeedTrace.java b/glass/src/main/java/dev/imb11/debug/FeedTrace.java new file mode 100644 index 0000000..c0224d0 --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/FeedTrace.java @@ -0,0 +1,21 @@ +package dev.imb11.debug; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public final class FeedTrace { + private static final Logger LOGGER = LoggerFactory.getLogger("glass/projection-renderer"); + private static final long SLOW_STAGE_NANOS = TimeUnit.MILLISECONDS.toNanos(20L); + private final LogThrottle slowStageLog = new LogThrottle(TimeUnit.SECONDS.toNanos(5L)); + + public void slowStage(String stage, long started, Supplier context) { + long now = System.nanoTime(); + long elapsed = now - started; + if (elapsed >= SLOW_STAGE_NANOS && slowStageLog.ready(now)) { + LOGGER.warn("[GLASS projector] slow stage={} durationMs={} {}", stage, elapsed / 1_000_000.0D, context.get()); + } + } +} diff --git a/glass/src/main/java/dev/imb11/debug/LogThrottle.java b/glass/src/main/java/dev/imb11/debug/LogThrottle.java new file mode 100644 index 0000000..46eb040 --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/LogThrottle.java @@ -0,0 +1,25 @@ +package dev.imb11.debug; + +public final class LogThrottle { + private final long interval; + private boolean logged; + private long last; + + public LogThrottle(long interval) { + this.interval = interval; + } + + public boolean ready(long now) { + if (logged && now - last < interval) { + return false; + } + logged = true; + last = now; + return true; + } + + public void reset(long now) { + logged = true; + last = now; + } +} diff --git a/glass/src/main/java/dev/imb11/debug/ProjectorActivationTrace.java b/glass/src/main/java/dev/imb11/debug/ProjectorActivationTrace.java new file mode 100644 index 0000000..2fab55d --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/ProjectorActivationTrace.java @@ -0,0 +1,79 @@ +package dev.imb11.debug; + +import com.mojang.logging.LogUtils; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import dev.imb11.client.renderer.projection.ProjectionStage; +import dev.imb11.projection.ProjectionSurface; +import net.minecraft.client.multiplayer.ClientLevel; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public final class ProjectorActivationTrace { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final long CHANGE_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(1L); + private static final long STALL_NANOS = TimeUnit.SECONDS.toNanos(5L); + private static final Map STATES = new IdentityHashMap<>(); + + private ProjectorActivationTrace() { + } + + public static void report(ProjectorBlockEntity projector, ProjectionStage stage, @Nullable ProjectionRenderManager.ProjectionFeed feed) { + State state = STATES.computeIfAbsent(projector, ignored -> new State()); + long now = System.nanoTime(); + boolean powered = projector.isActive(); + boolean powerChanged = state.powered != powered; + if (powerChanged) { + state.powered = powered; + state.poweredSince = now; + } + boolean ready = ProjectionRenderManager.isReady(feed); + long sinceLog = now - state.lastLog; + boolean stalled = powered && !ready && now - state.poweredSince >= STALL_NANOS; + boolean changed = stage != state.stage || ready != state.ready; + if (state.stage != null && !powerChanged && !(changed && sinceLog >= CHANGE_INTERVAL_NANOS) + && !(stalled && sinceLog >= STALL_NANOS)) { + return; + } + state.stage = stage; + state.ready = ready; + state.lastLog = now; + ProjectionSurface surface = projector.getProjectionSurface(); + String message = "[GLASS projector] activation dimension={} pos={} channel={} powered={} poweredMs={} stage={} ready={} reveal={} faces={} bounds={} {}"; + Object[] details = { + projector.getLevel().dimension().identifier(), projector.getBlockPos().toShortString(), projector.getChannel(), + powered, powered ? (now - state.poweredSince) / 1_000_000L : 0L, stage, ready, projector.getRevealDistance(), + surface == null ? 0 : surface.faces().size(), surface == null ? "none" : surface.renderBounds(), + feed == null ? "feed=none" : feed.diagnostics() + }; + if (stalled) { + LOGGER.warn(message, details); + } else { + LOGGER.info(message, details); + } + } + + public static void forget(ProjectorBlockEntity projector) { + STATES.remove(projector); + } + + public static void forgetLevel(ClientLevel level) { + STATES.keySet().removeIf(projector -> projector.getLevel() == level); + } + + public static void clear() { + STATES.clear(); + } + + private static final class State { + private ProjectionStage stage; + private boolean powered; + private boolean ready; + private long poweredSince; + private long lastLog; + } +} diff --git a/glass/src/main/java/dev/imb11/debug/ProjectorPowerTrace.java b/glass/src/main/java/dev/imb11/debug/ProjectorPowerTrace.java new file mode 100644 index 0000000..86dedd8 --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/ProjectorPowerTrace.java @@ -0,0 +1,20 @@ +package dev.imb11.debug; + +import com.mojang.logging.LogUtils; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import org.slf4j.Logger; + +public final class ProjectorPowerTrace { + private static final Logger LOGGER = LogUtils.getLogger(); + + private ProjectorPowerTrace() { + } + + public static void changed(Level level, BlockPos pos, String channel, String cause, boolean wasActive, boolean active, + int revealDistance) { + LOGGER.info("[GLASS projector] power side={} dimension={} pos={} channel={} cause={} active={}->{} reveal={} tick={}", + level.isClientSide() ? "client" : "server", level.dimension().identifier(), pos.toShortString(), + channel, cause, wasActive, active, revealDistance, level.getGameTime()); + } +} diff --git a/glass/src/main/java/dev/imb11/debug/RemoteSceneDiagnostics.java b/glass/src/main/java/dev/imb11/debug/RemoteSceneDiagnostics.java new file mode 100644 index 0000000..83178fb --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/RemoteSceneDiagnostics.java @@ -0,0 +1,80 @@ +package dev.imb11.debug; + +import net.minecraft.world.level.ChunkPos; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Predicate; + +public record RemoteSceneDiagnostics( + long epoch, + boolean subscribed, + @Nullable Object unavailable, + boolean terminal, + ChunkPos requestedCenter, + int requestedRadius, + @Nullable ChunkPos grantedCenter, + int grantedRadius, + boolean cameraWithinGrant, + int pending, + long pendingBytes, + int globalPending, + long globalPendingBytes, + long lastSequence, + long stalePackets, + long discardedApplies, + long lastSendAgeTicks, + @Nullable ChunkStats chunks +) { + @Override + public String toString() { + return "epoch=" + epoch + " subscribed=" + subscribed + " unavailable=" + unavailable + " terminal=" + terminal + + " requestedCenter=" + requestedCenter + " requestedRadius=" + requestedRadius + + " grantedCenter=" + grantedCenter + " grantedRadius=" + grantedRadius + + " cameraWithinGrant=" + cameraWithinGrant + " pending=" + pending + " pendingBytes=" + pendingBytes + + " globalPending=" + globalPending + " globalPendingBytes=" + globalPendingBytes + + " lastSequence=" + lastSequence + " stalePackets=" + stalePackets + + " discardedApplies=" + discardedApplies + " lastSendAgeTicks=" + lastSendAgeTicks + + " " + (chunks == null ? "chunks=unavailable" : chunks); + } + + public record ChunkStats( + boolean playerWorld, + long sharedSubscriptions, + boolean closed, + int expected, + int missing, + int unlit, + @Nullable ChunkPos firstMissing, + @Nullable ChunkPos firstUnlit + ) { + public static ChunkStats scan(boolean playerWorld, long sharedSubscriptions, boolean closed, ChunkPos center, int radius, + Predicate present, Predicate lit) { + int missing = 0; + int unlit = 0; + ChunkPos firstMissing = null; + ChunkPos firstUnlit = null; + for (int x = center.x() - radius; x <= center.x() + radius; x++) { + for (int z = center.z() - radius; z <= center.z() + radius; z++) { + ChunkPos pos = new ChunkPos(x, z); + if (!present.test(pos)) { + missing++; + firstMissing = firstMissing == null ? pos : firstMissing; + } else if (!lit.test(pos)) { + unlit++; + firstUnlit = firstUnlit == null ? pos : firstUnlit; + } + } + } + int width = radius * 2 + 1; + return new ChunkStats(playerWorld, sharedSubscriptions, closed, width * width, missing, unlit, firstMissing, firstUnlit); + } + + @Override + public String toString() { + return "storage=" + (playerWorld ? "player-world" : "remote-dimension") + + " sharedSubscriptions=" + sharedSubscriptions + " closed=" + closed + " expectedChunks=" + expected + + " missingChunks=" + missing + " unlitChunks=" + unlit + + " firstMissingChunk=" + firstMissing + " firstUnlitChunk=" + firstUnlit; + } + } +} diff --git a/glass/src/main/java/dev/imb11/debug/RemoteStreamTrace.java b/glass/src/main/java/dev/imb11/debug/RemoteStreamTrace.java new file mode 100644 index 0000000..f123036 --- /dev/null +++ b/glass/src/main/java/dev/imb11/debug/RemoteStreamTrace.java @@ -0,0 +1,122 @@ +package dev.imb11.debug; + +import com.mojang.logging.LogUtils; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.LevelChunk; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.util.Queue; +import java.util.UUID; + +public final class RemoteStreamTrace { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final int PENDING_LOG_INTERVAL_TICKS = 100; + private static final int COMPLETE_LOG_INTERVAL_TICKS = 20; + private final LogThrottle grantLog = new LogThrottle(PENDING_LOG_INTERVAL_TICKS); + private int lastProgressLogTick; + private boolean completeLogged; + private long reusedPlayerChunks; + private long reusedProjectionChunks; + private long transferredChunks; + + public RemoteStreamTrace(int now) { + lastProgressLogTick = now; + } + + public static void accepted(UUID player, Object id, ChunkPos center, int radius) { + LOGGER.info("[GLASS projector] server accepted subscribe player={} id={} center={} radius={}", player, id, center, radius); + } + + public static void rejected(String request, UUID player, Object id, int radius, @Nullable Object denial, String details) { + LOGGER.warn("[GLASS projector] server rejected {} player={} id={} radius={} denial={} {}", + request, player, id, radius, denial, details); + } + + public static void rejectedStale(UUID player, Object id, long latestEpoch) { + LOGGER.warn("[GLASS projector] server rejected stale subscribe player={} id={} latestEpoch={}", player, id, latestEpoch); + } + + public static void chunkFailed(Object id, ChunkPos pos, Exception exception) { + LOGGER.warn("[GLASS projector] cannot prepare chunk {} for {}", pos, id, exception); + } + + public void reusedPlayerChunk() { + reusedPlayerChunks++; + } + + public void reusedProjectionChunk() { + reusedProjectionChunks++; + } + + public void transferredChunk() { + transferredChunks++; + } + + public void granted(Summary summary, int requestedRadius, int added, int removed, boolean unsupported, int now) { + if (!grantLog.ready(now) && summary.radius() > 0) { + return; + } + grantLog.reset(now); + LOGGER.info("[GLASS projector] server grant id={} center={} radius={} requestedRadius={} chunks={} added={} removed={} pending={} unsupported={}", + summary.id(), summary.center(), summary.radius(), requestedRadius, summary.interest(), added, removed, + summary.pending().size(), unsupported); + } + + public void progress(Summary summary, @Nullable ServerLevel level, int now) { + if (summary.pending().isEmpty() && summary.inFlight() == 0) { + if (!completeLogged && now - lastProgressLogTick >= COMPLETE_LOG_INTERVAL_TICKS) { + LOGGER.info("[GLASS projector] server chunks ready id={} center={} radius={} available={}/{} tickets={} sequence={} {}", + summary.id(), summary.center(), summary.radius(), summary.sent(), summary.interest(), summary.tickets(), + summary.sequence(), counters()); + completeLogged = true; + lastProgressLogTick = now; + } + return; + } + completeLogged = false; + if (now - lastProgressLogTick < PENDING_LOG_INTERVAL_TICKS) { + return; + } + lastProgressLogTick = now; + int missing = 0; + int unlit = 0; + for (ChunkPos pos : summary.pending()) { + LevelChunk chunk = level == null ? null : level.getChunkSource().getChunkNow(pos.x(), pos.z()); + if (chunk == null) { + missing++; + } else if (!chunk.isLightCorrect()) { + unlit++; + } + } + LOGGER.warn("[GLASS projector] server chunk stream pending id={} center={} radius={} sent={}/{} pending={} missing={} unlit={} nextChunk={} tickets={} heartbeatAgeTicks={} sequence={} inFlight={} {}", + summary.id(), summary.center(), summary.radius(), summary.sent(), summary.interest(), summary.pending().size(), + missing, unlit, summary.pending().peek(), summary.tickets(), summary.heartbeatAgeTicks(), summary.sequence(), + summary.inFlight(), counters()); + } + + public void closed(Summary summary, Object reason, boolean notify, @Nullable Object denial) { + LOGGER.info("[GLASS projector] server closed id={} reason={} notify={} sent={}/{} pending={} tickets={} heartbeatAgeTicks={} denial={} {}", + summary.id(), reason, notify, summary.sent(), summary.interest(), summary.pending().size(), summary.tickets(), + summary.heartbeatAgeTicks(), denial, counters()); + } + + private String counters() { + return "reusedPlayer=" + reusedPlayerChunks + " reusedProjection=" + reusedProjectionChunks + " transferred=" + transferredChunks; + } + + public record Summary( + Object id, + ChunkPos center, + int radius, + int sent, + int interest, + Queue pending, + int tickets, + int heartbeatAgeTicks, + long sequence, + int inFlight + ) { + } +} diff --git a/glass/src/main/java/dev/imb11/fabric/GlassFabric.java b/glass/src/main/java/dev/imb11/fabric/GlassFabric.java new file mode 100644 index 0000000..6fcb705 --- /dev/null +++ b/glass/src/main/java/dev/imb11/fabric/GlassFabric.java @@ -0,0 +1,50 @@ +//? fabric { +package dev.imb11.fabric; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.items.GItems; +import dev.imb11.sounds.GSounds; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.entity.event.v1.ServerEntityLevelChangeEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerBlockEntityEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents; +import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.world.item.CreativeModeTabs; + +public final class GlassFabric implements ModInitializer { + @Override + public void onInitialize() { + RemoteSceneServerManager.registerTicketType(); + GBlocks.registerBlocks(); + GBlocks.registerBlockEntities(); + GBlocks.registerMenus(); + GItems.init(); + GSounds.init(); + GNetworking.initialize(); + + CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.REDSTONE_BLOCKS).register(entries -> { + entries.accept(GItems.TERMINAL); + entries.accept(GItems.PROJECTOR); + entries.accept(GItems.REDSTONE_INFUSED_SAND); + entries.accept(GItems.POWERABLE_GLASS); + }); + ServerTickEvents.END_SERVER_TICK.register(RemoteSceneServerManager::tick); + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> GNetworking.sendSnapshot(handler.getPlayer())); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> RemoteSceneServerManager.removePlayer(server, handler.getPlayer(), false)); + ServerEntityLevelChangeEvents.AFTER_PLAYER_CHANGE_LEVEL.register((player, origin, destination) -> { + RemoteSceneServerManager.removePlayer(destination.getServer(), player, true); + GNetworking.sendSnapshot(player); + }); + ServerLevelEvents.LOAD.register((server, level) -> ChannelManagerPersistence.onLevelLoad(level)); + ServerLevelEvents.UNLOAD.register(RemoteSceneServerManager::unloadWorld); + ServerLifecycleEvents.SERVER_STOPPING.register(RemoteSceneServerManager::stop); + ServerBlockEntityEvents.BLOCK_ENTITY_LOAD.register(ChannelManagerPersistence::onBlockEntityLoad); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/fabric/GlassFabricClient.java b/glass/src/main/java/dev/imb11/fabric/GlassFabricClient.java new file mode 100644 index 0000000..312b740 --- /dev/null +++ b/glass/src/main/java/dev/imb11/fabric/GlassFabricClient.java @@ -0,0 +1,37 @@ +//? fabric { +package dev.imb11.fabric; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.client.GlassClient; +import dev.imb11.client.gui.ProjectorBlockGUI; +import dev.imb11.client.gui.ProjectorBlockScreen; +import dev.imb11.client.gui.TerminalBlockGUI; +import dev.imb11.client.gui.TerminalBlockScreen; +import dev.imb11.client.renderer.block.ProjectorBlockEntityRenderer; +import dev.imb11.client.renderer.block.TerminalBlockEntityRenderer; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientBlockEntityEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientChunkEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderers; + +public final class GlassFabricClient implements ClientModInitializer { + @Override + public void onInitializeClient() { + GlassClient.initialize(); + BlockEntityRenderers.register(TerminalBlockEntity.BLOCK_ENTITY_TYPE, TerminalBlockEntityRenderer::new); + ClientPlayConnectionEvents.INIT.register((handler, client) -> GlassClient.clearConnection()); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> GlassClient.clearConnection()); + ClientTickEvents.START_CLIENT_TICK.register(GlassClient::tick); + ClientChunkEvents.CHUNK_UNLOAD.register(GlassClient::onChunkUnload); + ClientBlockEntityEvents.BLOCK_ENTITY_LOAD.register(GlassClient::onBlockEntityLoad); + ClientBlockEntityEvents.BLOCK_ENTITY_UNLOAD.register(GlassClient::onBlockEntityUnload); + MenuScreens.register(TerminalBlockGUI.SCREEN_HANDLER_TYPE, TerminalBlockScreen::new); + MenuScreens.register(ProjectorBlockGUI.SCREEN_HANDLER_TYPE, ProjectorBlockScreen::new); + } +} +//?} diff --git a/glass/src/main/java/dev/imb11/items/GItems.java b/glass/src/main/java/dev/imb11/items/GItems.java new file mode 100644 index 0000000..f41b38b --- /dev/null +++ b/glass/src/main/java/dev/imb11/items/GItems.java @@ -0,0 +1,29 @@ +package dev.imb11.items; + +import dev.imb11.Glass; +import dev.imb11.blocks.GBlocks; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; + +public class GItems { + public static final BlockItem TERMINAL = new BlockItem(GBlocks.TERMINAL, new Item.Properties().setId(ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath(Glass.MOD_ID, "terminal"))).useBlockDescriptionPrefix()); + public static final BlockItem PROJECTOR = new BlockItem(GBlocks.PROJECTOR, new Item.Properties().setId(ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath(Glass.MOD_ID, "projector"))).useBlockDescriptionPrefix()); + public static final BlockItem REDSTONE_INFUSED_SAND = new BlockItem(GBlocks.REDSTONE_INFUSED_SAND, new Item.Properties().setId(ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath(Glass.MOD_ID, "redstone_infused_sand"))).useBlockDescriptionPrefix()); + public static final BlockItem POWERABLE_GLASS = new BlockItem(GBlocks.POWERABLE_GLASS, new Item.Properties().setId(ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath(Glass.MOD_ID, "powerable_glass"))).useBlockDescriptionPrefix()); + + public static void init() { + register("terminal", TERMINAL); + register("projector", PROJECTOR); + register("redstone_infused_sand", REDSTONE_INFUSED_SAND); + register("powerable_glass", POWERABLE_GLASS); + } + + private static T register(String id, T item) { + return Registry.register(BuiltInRegistries.ITEM, Identifier.fromNamespaceAndPath(Glass.MOD_ID, id), item); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/BufferSourceAccessor.java b/glass/src/main/java/dev/imb11/mixins/BufferSourceAccessor.java new file mode 100644 index 0000000..d5910da --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/BufferSourceAccessor.java @@ -0,0 +1,18 @@ +package dev.imb11.mixins; + +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.rendertype.RenderType; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.util.SequencedMap; + +@Mixin(MultiBufferSource.BufferSource.class) +public interface BufferSourceAccessor { + @Accessor("sharedBuffer") + ByteBufferBuilder glass$getSharedBuffer(); + + @Accessor("fixedBuffers") + SequencedMap glass$getFixedBuffers(); +} diff --git a/glass/src/main/java/dev/imb11/mixins/ChunkMapAccessor.java b/glass/src/main/java/dev/imb11/mixins/ChunkMapAccessor.java new file mode 100644 index 0000000..e69c1e5 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ChunkMapAccessor.java @@ -0,0 +1,21 @@ +package dev.imb11.mixins; + +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.minecraft.server.level.ChunkMap; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.chunk.LevelChunk; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(ChunkMap.class) +public interface ChunkMapAccessor { + @Accessor("entityMap") + Int2ObjectMap glass$getEntityMap(); + + @Invoker("isChunkTracked") + boolean glass$isChunkTracked(ServerPlayer player, int x, int z); + + @Invoker("getChunkToSend") + LevelChunk glass$getChunkToSend(long chunk); +} diff --git a/glass/src/main/java/dev/imb11/mixins/ChunkMapMixin.java b/glass/src/main/java/dev/imb11/mixins/ChunkMapMixin.java new file mode 100644 index 0000000..b6446b1 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ChunkMapMixin.java @@ -0,0 +1,26 @@ +package dev.imb11.mixins; + +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.minecraft.server.level.ChunkMap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.ChunkAccess; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; + +@Mixin(ChunkMap.class) +public abstract class ChunkMapMixin { + @Shadow + @Final + private ServerLevel level; + + @Inject(method = "resendBiomesForChunks", at = @At("TAIL")) + private void glass$remoteBiomesChanged(List chunks, CallbackInfo ci) { + RemoteSceneServerManager.onBiomesChanged(level, chunks); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/ClientChunkCacheMixin.java b/glass/src/main/java/dev/imb11/mixins/ClientChunkCacheMixin.java new file mode 100644 index 0000000..9b65bb8 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ClientChunkCacheMixin.java @@ -0,0 +1,83 @@ +package dev.imb11.mixins; + +import dev.imb11.client.compat.SodiumCompatibility; +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import dev.imb11.client.remote.ProjectionChunkAccess; +import dev.imb11.client.remote.ProjectionChunkStorage; +import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.SectionPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.function.Consumer; + +@Mixin(ClientChunkCache.class) +abstract class ClientChunkCacheMixin implements ProjectionChunkAccess { + @Shadow @Final private ClientLevel level; + @Unique private final ProjectionChunkStorage glass$chunks = new ProjectionChunkStorage((ClientChunkCache) (Object) this); + + @Override + public ProjectionChunkStorage glass$projectionChunks() { + return glass$chunks; + } + + @Inject(method = "getChunk(IILnet/minecraft/world/level/chunk/status/ChunkStatus;Z)Lnet/minecraft/world/level/chunk/LevelChunk;", at = @At("HEAD"), cancellable = true) + private void glass$getRetainedChunk(int x, int z, ChunkStatus status, boolean load, CallbackInfoReturnable cir) { + LevelChunk chunk = glass$chunks.get(x, z); + if (chunk != null) { + cir.setReturnValue(chunk); + } + } + + @Inject(method = "replaceWithPacketData", at = @At("HEAD"), cancellable = true) + private void glass$updateRetainedChunk(int x, int z, FriendlyByteBuf data, java.util.Map heightmaps, + Consumer blockEntities, + CallbackInfoReturnable cir) { + if (glass$chunks.contains(x, z)) { + LevelChunk chunk = glass$chunks.replace(level, x, z, data, heightmaps, blockEntities, true); + SodiumCompatibility.onChunkReadyToRender(level, new ChunkPos(x, z)); + cir.setReturnValue(chunk); + } + } + + @Inject(method = "drop", at = @At("HEAD")) + private void glass$retainProjectionChunk(ChunkPos pos, CallbackInfo ci) { + if (glass$chunks.dropVanilla(level, pos)) { + SodiumCompatibility.onChunkUnloaded(level, pos); + } + } + + @Inject(method = "replaceBiomes", at = @At("HEAD"), cancellable = true) + private void glass$updateRetainedBiomes(int x, int z, FriendlyByteBuf data, CallbackInfo ci) { + LevelChunk chunk = glass$chunks.get(x, z); + if (chunk != null) { + chunk.replaceBiomes(data); + ci.cancel(); + } + } + + @Inject(method = "onLightUpdate", at = @At("HEAD"), cancellable = true) + private void glass$routeRemoteLightUpdate(LightLayer layer, SectionPos sectionPos, CallbackInfo callbackInfo) { + if (ProjectionRenderContext.routeRemoteLightUpdate( + (ClientChunkCache) (Object) this, + layer, + sectionPos + )) { + callbackInfo.cancel(); + } + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/ClientLevelMixin.java b/glass/src/main/java/dev/imb11/mixins/ClientLevelMixin.java new file mode 100644 index 0000000..92a1d52 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ClientLevelMixin.java @@ -0,0 +1,35 @@ +package dev.imb11.mixins; + +import dev.imb11.client.compat.SodiumCompatibility; +import dev.imb11.client.remote.ProjectionChunkStorage; +import dev.imb11.client.remote.RemoteSceneClientManager; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.chunk.LevelChunk; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ClientLevel.class) +abstract class ClientLevelMixin { + @Inject(method = "unload", at = @At("HEAD"), cancellable = true) + private void glass$retainChunk(LevelChunk chunk, CallbackInfo ci) { + if (ProjectionChunkStorage.of((ClientLevel) (Object) this).protectFromUnload(chunk)) { + SodiumCompatibility.onChunkUnloaded((ClientLevel) (Object) this, chunk.getPos()); + ci.cancel(); + } + } + + @Inject(method = "addEntity", at = @At("HEAD")) + private void glass$nativeEntityAdded(Entity entity, CallbackInfo ci) { + RemoteSceneClientManager.onVanillaEntityAdded((ClientLevel) (Object) this, entity); + } + + @Inject(method = "removeEntity", at = @At("HEAD"), cancellable = true) + private void glass$retainEntity(int id, Entity.RemovalReason reason, CallbackInfo ci) { + if (RemoteSceneClientManager.onVanillaEntityRemoved((ClientLevel) (Object) this, id)) { + ci.cancel(); + } + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/ClientPacketListenerMixin.java b/glass/src/main/java/dev/imb11/mixins/ClientPacketListenerMixin.java new file mode 100644 index 0000000..4b4e910 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ClientPacketListenerMixin.java @@ -0,0 +1,21 @@ +package dev.imb11.mixins; + +import dev.imb11.client.remote.ProjectionChunkStorage; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.network.protocol.game.ClientboundForgetLevelChunkPacket; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ClientPacketListener.class) +abstract class ClientPacketListenerMixin { + @Redirect(method = "queueLightRemoval", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/multiplayer/ClientLevel;queueLightUpdate(Ljava/lang/Runnable;)V")) + private void glass$retainProjectionLight(ClientLevel level, Runnable removal, ClientboundForgetLevelChunkPacket packet) { + level.queueLightUpdate(() -> { + if (!ProjectionChunkStorage.of(level).retained(packet.pos())) { + removal.run(); + } + }); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/GameRendererMixin.java b/glass/src/main/java/dev/imb11/mixins/GameRendererMixin.java new file mode 100644 index 0000000..743e64b --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/GameRendererMixin.java @@ -0,0 +1,52 @@ +package dev.imb11.mixins; + +import dev.imb11.client.renderer.block.ProjectorBlockEntityRenderer; +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import dev.imb11.client.renderer.projection.ProjectionSurfaceRenderer; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.GameRenderer; +import dev.imb11.client.renderer.projection.ProjectionLightmap; +import com.mojang.blaze3d.textures.GpuTextureView; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(GameRenderer.class) +abstract class GameRendererMixin { + @Inject(method = {"lightmap", "levelLightmap"}, at = @At("HEAD"), cancellable = true) + private void glass$projectionProjectionLightmap(CallbackInfoReturnable callbackInfo) { + ProjectionLightmap lightTexture = ProjectionRenderContext.lightTexture(); + if (lightTexture != null) { + callbackInfo.setReturnValue(lightTexture.getTextureView()); + } + } + + @Inject(method = "renderLevel", at = @At("HEAD")) + private void glass$renderProjectionBeforeMain(DeltaTracker deltaTracker, CallbackInfo callbackInfo) { + ProjectionRenderManager.renderBeforeMain((GameRenderer) (Object) this, deltaTracker); + } + + @Inject(method = "renderLevel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/LevelRenderer;renderLevel(Lcom/mojang/blaze3d/resource/GraphicsResourceAllocator;Lnet/minecraft/client/DeltaTracker;ZLnet/minecraft/client/renderer/state/level/CameraRenderState;Lorg/joml/Matrix4fc;Lcom/mojang/blaze3d/buffers/GpuBufferSlice;Lorg/joml/Vector4f;ZLnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;)V", shift = At.Shift.AFTER)) + private void glass$renderProjectors(DeltaTracker deltaTracker, CallbackInfo callbackInfo) { + var state = ((GameRenderer) (Object) this).getGameRenderState().levelRenderState.cameraRenderState; + ProjectorBlockEntityRenderer.renderAll(new org.joml.Matrix4f(state.viewRotationMatrix)); + } + + @Inject(method = "resetData", at = @At("HEAD")) + private void glass$resetProjectionRenderer(CallbackInfo callbackInfo) { + ProjectionRenderManager.reset(); + ProjectionSurfaceRenderer.reset(); + ProjectorBlockEntityRenderer.reset(); + } + + @Inject(method = "close", at = @At("HEAD")) + private void glass$closeProjectionRenderer(CallbackInfo callbackInfo) { + ProjectionRenderManager.reset(); + ProjectionSurfaceRenderer.reset(); + ProjectorBlockEntityRenderer.reset(); + ProjectorBlockEntityRenderer.clearLoaded(); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/LevelRendererBufferAccessor.java b/glass/src/main/java/dev/imb11/mixins/LevelRendererBufferAccessor.java new file mode 100644 index 0000000..c4b383f --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/LevelRendererBufferAccessor.java @@ -0,0 +1,29 @@ +package dev.imb11.mixins; + +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.ViewArea; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import net.minecraft.world.level.block.entity.BlockEntity; +import java.util.Set; + +@Mixin(LevelRenderer.class) +public interface LevelRendererBufferAccessor { + @Accessor("viewArea") + ViewArea glass$getViewArea(); + + @Accessor("viewArea") + void glass$setViewArea(ViewArea viewArea); + + @Accessor("level") + void glass$setLevel(ClientLevel level); + + @Accessor("visibleSections") + ObjectArrayList glass$getVisibleSections(); + + @Accessor("sectionRenderDispatcher") + void glass$setSectionRenderDispatcher(SectionRenderDispatcher dispatcher); +} diff --git a/glass/src/main/java/dev/imb11/mixins/LevelRendererMixin.java b/glass/src/main/java/dev/imb11/mixins/LevelRendererMixin.java new file mode 100644 index 0000000..94ea1e3 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/LevelRendererMixin.java @@ -0,0 +1,74 @@ +package dev.imb11.mixins; + +import dev.imb11.client.renderer.block.ProjectorBlockEntityRenderer; +import dev.imb11.client.GlassClient; +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(value = LevelRenderer.class, priority = 2000) +abstract class LevelRendererMixin { + @Inject(method = "tick", at = @At("TAIL")) + private void glass$mirrorMainRendererTick(CallbackInfo callbackInfo) { + ProjectionRenderManager.onMainRendererTick((LevelRenderer) (Object) this); + } + + @Inject(method = "allChanged", at = @At("HEAD")) + private void glass$invalidateRenderState(CallbackInfo callbackInfo) { + GlassClient.invalidateRenderState(); + } + + @Inject(method = "allChanged", at = @At("TAIL")) + private void glass$mirrorMainRendererRebuild(CallbackInfo callbackInfo) { + ProjectorBlockEntityRenderer.onMainRendererRebuilt((LevelRenderer) (Object) this); + ProjectionRenderManager.onMainRendererRebuilt((LevelRenderer) (Object) this); + } + + @Inject(method = "setSectionDirty(IIIZ)V", at = @At("TAIL")) + private void glass$mirrorSectionDirty( + int sectionX, + int sectionY, + int sectionZ, + boolean playerChanged, + CallbackInfo callbackInfo + ) { + ProjectionRenderManager.onMainSectionDirty( + (LevelRenderer) (Object) this, + sectionX, + sectionY, + sectionZ + ); + } + + @Inject(method = "onChunkReadyToRender", at = @At("TAIL")) + private void glass$mirrorChunkLoaded(ChunkPos chunkPos, CallbackInfo callbackInfo) { + ProjectionRenderManager.onMainChunkLoaded((LevelRenderer) (Object) this, chunkPos); + } + @Inject(method = "onResourceManagerReload", at = @At("HEAD")) + private void glass$reload(CallbackInfo callbackInfo) { + if ((Object) this == Minecraft.getInstance().levelRenderer) { + GlassClient.onResourceReload(); + } + } + + @Inject(method = "extractVisibleBlockEntities", at = @At("HEAD"), cancellable = true) + private void glass$extractProjectionBlockEntities(Camera camera, float partialTick, + net.minecraft.client.renderer.state.level.LevelRenderState state, CallbackInfo callbackInfo) { + if ((Object) this instanceof dev.imb11.client.renderer.projection.ProjectionLevelRenderer renderer) { + renderer.extractProjectionBlockEntities(partialTick, state); + callbackInfo.cancel(); + } + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/MinecraftMixin.java b/glass/src/main/java/dev/imb11/mixins/MinecraftMixin.java new file mode 100644 index 0000000..5980aa7 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/MinecraftMixin.java @@ -0,0 +1,27 @@ +package dev.imb11.mixins; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Minecraft.class) +abstract class MinecraftMixin { + @Inject(method = "getMainRenderTarget", at = @At("HEAD"), cancellable = true) + private void glass$projectionMainTarget(CallbackInfoReturnable callbackInfo) { + RenderTarget projectionTarget = ProjectionRenderContext.target(); + if (projectionTarget != null) { + callbackInfo.setReturnValue(projectionTarget); + } + } + + @Inject(method = "useShaderTransparency", at = @At("HEAD"), cancellable = true) + private static void glass$useForwardTransparency(CallbackInfoReturnable callbackInfo) { + if (ProjectionRenderContext.isActive()) { + callbackInfo.setReturnValue(false); + } + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/ProjectionRendererAccessor.java b/glass/src/main/java/dev/imb11/mixins/ProjectionRendererAccessor.java new file mode 100644 index 0000000..28536fb --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ProjectionRendererAccessor.java @@ -0,0 +1,46 @@ +package dev.imb11.mixins; + +import com.mojang.blaze3d.buffers.GpuBufferSlice; +import com.mojang.blaze3d.framegraph.FrameGraphBuilder; +import net.minecraft.client.CloudStatus; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.LevelTargetBundle; +import net.minecraft.client.renderer.SkyRenderer; +import net.minecraft.client.renderer.chunk.ChunkSectionsToRender; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4fc; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(LevelRenderer.class) +public interface ProjectionRendererAccessor { + @Accessor("targets") + LevelTargetBundle glass$targets(); + + @Accessor("skyRenderer") + void glass$skyRenderer(SkyRenderer renderer); + + @Accessor("ticks") + void glass$ticks(int ticks); + + @Invoker("addSkyPass") + void glass$skyPass(FrameGraphBuilder frame, CameraRenderState camera, GpuBufferSlice fog); + + @Invoker("addMainPass") + void glass$mainPass(FrameGraphBuilder frame, Frustum frustum, Matrix4fc modelView, GpuBufferSlice fog, + boolean outline, LevelRenderState state, DeltaTracker delta, ProfilerFiller profiler, + ChunkSectionsToRender chunks); + + @Invoker("addCloudsPass") + void glass$cloudsPass(FrameGraphBuilder frame, CloudStatus status, Vec3 camera, long time, + float partialTick, int color, float height, int range); + + @Invoker("addWeatherPass") + void glass$weatherPass(FrameGraphBuilder frame, GpuBufferSlice fog); +} diff --git a/glass/src/main/java/dev/imb11/mixins/RenderChunkRegionMixin.java b/glass/src/main/java/dev/imb11/mixins/RenderChunkRegionMixin.java new file mode 100644 index 0000000..5a7daca --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/RenderChunkRegionMixin.java @@ -0,0 +1,60 @@ +package dev.imb11.mixins; + +import dev.imb11.client.renderer.projection.ProjectionRenderRegion; +import net.minecraft.client.renderer.chunk.RenderSectionRegion; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FluidState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(RenderSectionRegion.class) +abstract class RenderChunkRegionMixin implements ProjectionRenderRegion { + @Unique + private BlockPos glass$hiddenTerrainBlock; + + @Override + public void glass$setHiddenTerrainBlock(BlockPos position) { + glass$hiddenTerrainBlock = position == null ? null : position.immutable(); + } + + @Inject(method = "getBlockState", at = @At("HEAD"), cancellable = true) + private void glass$hideProjectionTerminalState( + BlockPos position, + CallbackInfoReturnable callbackInfo + ) { + if (glass$isHiddenTerrainBlock(position)) { + callbackInfo.setReturnValue(Blocks.AIR.defaultBlockState()); + } + } + + @Inject(method = "getFluidState", at = @At("HEAD"), cancellable = true) + private void glass$hideProjectionTerminalFluid( + BlockPos position, + CallbackInfoReturnable callbackInfo + ) { + if (glass$isHiddenTerrainBlock(position)) { + callbackInfo.setReturnValue(Blocks.AIR.defaultBlockState().getFluidState()); + } + } + + @Inject(method = "getBlockEntity", at = @At("HEAD"), cancellable = true) + private void glass$hideProjectionTerminalBlockEntity( + BlockPos position, + CallbackInfoReturnable callbackInfo + ) { + if (glass$isHiddenTerrainBlock(position)) { + callbackInfo.setReturnValue(null); + } + } + + @Unique + private boolean glass$isHiddenTerrainBlock(BlockPos position) { + return glass$hiddenTerrainBlock != null && glass$hiddenTerrainBlock.equals(position); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/RenderRegionCacheMixin.java b/glass/src/main/java/dev/imb11/mixins/RenderRegionCacheMixin.java new file mode 100644 index 0000000..9ad690a --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/RenderRegionCacheMixin.java @@ -0,0 +1,25 @@ +package dev.imb11.mixins; + +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import dev.imb11.client.renderer.projection.ProjectionRenderRegion; +import net.minecraft.client.renderer.chunk.RenderSectionRegion; +import net.minecraft.client.renderer.chunk.RenderRegionCache; +import net.minecraft.core.BlockPos; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(RenderRegionCache.class) +abstract class RenderRegionCacheMixin { + @Inject(method = "createRegion", at = @At("RETURN")) + private void glass$captureProjectionTerminal( + CallbackInfoReturnable callbackInfo + ) { + BlockPos hiddenTerrainBlock = ProjectionRenderContext.hiddenTerrainBlock(); + RenderSectionRegion region = callbackInfo.getReturnValue(); + if (hiddenTerrainBlock != null && region != null) { + ((ProjectionRenderRegion) region).glass$setHiddenTerrainBlock(hiddenTerrainBlock); + } + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/ServerChunkCacheMixin.java b/glass/src/main/java/dev/imb11/mixins/ServerChunkCacheMixin.java new file mode 100644 index 0000000..b959219 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ServerChunkCacheMixin.java @@ -0,0 +1,25 @@ +package dev.imb11.mixins; + +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.LightLayer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerChunkCache.class) +public abstract class ServerChunkCacheMixin { + @Inject(method = "blockChanged", at = @At("TAIL")) + private void glass$remoteBlockChanged(BlockPos pos, CallbackInfo ci) { + RemoteSceneServerManager.onBlockChanged((ServerLevel) ((ServerChunkCache) (Object) this).getLevel(), pos); + } + + @Inject(method = "onLightUpdate", at = @At("TAIL")) + private void glass$remoteLightChanged(LightLayer layer, SectionPos pos, CallbackInfo ci) { + RemoteSceneServerManager.onLightChanged((ServerLevel) ((ServerChunkCache) (Object) this).getLevel(), layer, pos); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/TextureAtlasMixin.java b/glass/src/main/java/dev/imb11/mixins/TextureAtlasMixin.java new file mode 100644 index 0000000..94fefe9 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/TextureAtlasMixin.java @@ -0,0 +1,23 @@ +package dev.imb11.mixins; + +import dev.imb11.client.compat.SodiumCompatibility; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.renderer.texture.TextureAtlas; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; + +@Mixin(TextureAtlas.class) +abstract class TextureAtlasMixin { + @Shadow + private List sprites; + + @Inject(method = "cycleAnimationFrames", at = @At("HEAD")) + private void glass$animateProjectionTextures(CallbackInfo callbackInfo) { + SodiumCompatibility.activateProjectionTextures(sprites); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/TrackedEntityAccessor.java b/glass/src/main/java/dev/imb11/mixins/TrackedEntityAccessor.java new file mode 100644 index 0000000..0c9ca36 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/TrackedEntityAccessor.java @@ -0,0 +1,16 @@ +package dev.imb11.mixins; + +import net.minecraft.server.level.ServerEntity; +import net.minecraft.server.network.ServerPlayerConnection; +import java.util.Set; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(targets = "net.minecraft.server.level.ChunkMap$TrackedEntity") +public interface TrackedEntityAccessor { + @Accessor("serverEntity") + ServerEntity glass$getServerEntity(); + + @Accessor("seenBy") + Set glass$getSeenBy(); +} diff --git a/glass/src/main/java/dev/imb11/mixins/TrackedEntityMixin.java b/glass/src/main/java/dev/imb11/mixins/TrackedEntityMixin.java new file mode 100644 index 0000000..7899ecb --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/TrackedEntityMixin.java @@ -0,0 +1,28 @@ +package dev.imb11.mixins; + +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.minecraft.network.protocol.Packet; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(targets = "net.minecraft.server.level.ChunkMap$TrackedEntity") +abstract class TrackedEntityMixin { + @Shadow + @Final + private Entity entity; + + @Inject(method = "sendToTrackingPlayers", at = @At("TAIL")) + private void glass$broadcastRemoteEntity(Packet packet, CallbackInfo ci) { + RemoteSceneServerManager.onEntityPacket(entity, packet); + } + + @Inject(method = "broadcastRemoved", at = @At("TAIL")) + private void glass$removeRemoteEntity(CallbackInfo ci) { + RemoteSceneServerManager.onEntityRemoved(entity); + } +} diff --git a/glass/src/main/java/dev/imb11/mixins/ViewAreaInvoker.java b/glass/src/main/java/dev/imb11/mixins/ViewAreaInvoker.java new file mode 100644 index 0000000..e492e74 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/ViewAreaInvoker.java @@ -0,0 +1,13 @@ +package dev.imb11.mixins; + +import net.minecraft.client.renderer.ViewArea; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.core.BlockPos; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(ViewArea.class) +public interface ViewAreaInvoker { + @Invoker("getRenderSectionAt") + SectionRenderDispatcher.RenderSection glass$getRenderSectionAt(BlockPos pos); +} diff --git a/glass/src/main/java/dev/imb11/mixins/compat/SodiumWorldRendererMixin.java b/glass/src/main/java/dev/imb11/mixins/compat/SodiumWorldRendererMixin.java new file mode 100644 index 0000000..525a665 --- /dev/null +++ b/glass/src/main/java/dev/imb11/mixins/compat/SodiumWorldRendererMixin.java @@ -0,0 +1,31 @@ +package dev.imb11.mixins.compat; + +import dev.imb11.client.renderer.projection.ProjectionRenderContext; +import dev.imb11.client.renderer.projection.ProjectionRenderManager; +import net.caffeinemc.mods.sodium.client.render.SodiumWorldRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Pseudo +@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.SodiumWorldRenderer", remap = false) +abstract class SodiumWorldRendererMixin { + @Inject(method = "isEntityVisible", at = @At("HEAD"), cancellable = true) + private void glass$projectionEntityVisibility(net.minecraft.client.renderer.entity.EntityRenderer renderer, Entity entity, CallbackInfoReturnable callbackInfo) { + if (ProjectionRenderContext.isActive()) { + callbackInfo.setReturnValue(true); + } + } + + @Inject(method = "scheduleRebuildForChunk", at = @At("TAIL")) + private void glass$mirrorSectionRebuild(int x, int y, int z, boolean important, CallbackInfo callbackInfo) { + if ((Object) this == SodiumWorldRenderer.instanceNullable()) { + ProjectionRenderManager.onMainSectionDirty(Minecraft.getInstance().levelRenderer, x, y, z); + } + } +} diff --git a/glass/src/main/java/dev/imb11/neoforge/GlassNeoForge.java b/glass/src/main/java/dev/imb11/neoforge/GlassNeoForge.java new file mode 100644 index 0000000..b1ffc51 --- /dev/null +++ b/glass/src/main/java/dev/imb11/neoforge/GlassNeoForge.java @@ -0,0 +1,106 @@ +//? neoforge { +/*package dev.imb11.neoforge; + +import dev.imb11.Glass; +import dev.imb11.blocks.GBlocks; +import dev.imb11.items.GItems; +import dev.imb11.platform.PlatformNetworking; +import dev.imb11.sounds.GSounds; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import dev.imb11.sync.remote.RemoteSceneServerManager; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.CreativeModeTabs; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.BuildCreativeModeTabContentsEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.registries.RegisterEvent; + +@Mod(Glass.MOD_ID) +public final class GlassNeoForge { + public GlassNeoForge(IEventBus modBus) { + GNetworking.initialize(); + modBus.addListener(GlassNeoForge::register); + modBus.addListener(GlassNeoForge::creativeTab); + modBus.addListener(PlatformNetworking::registerPayloads); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::serverTick); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::playerJoined); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::playerLeft); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::playerChangedDimension); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::levelLoaded); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::levelUnloaded); + NeoForge.EVENT_BUS.addListener(GlassNeoForge::serverStopping); + } + + private static void register(RegisterEvent event) { + if (event.getRegistryKey().equals(Registries.TICKET_TYPE)) { + RemoteSceneServerManager.registerTicketType(); + } else if (event.getRegistryKey().equals(Registries.BLOCK)) { + GBlocks.registerBlocks(); + } else if (event.getRegistryKey().equals(Registries.ITEM)) { + GItems.init(); + } else if (event.getRegistryKey().equals(Registries.BLOCK_ENTITY_TYPE)) { + GBlocks.registerBlockEntities(); + } else if (event.getRegistryKey().equals(Registries.MENU)) { + GBlocks.registerMenus(); + } else if (event.getRegistryKey().equals(Registries.SOUND_EVENT)) { + GSounds.init(); + } + } + + private static void creativeTab(BuildCreativeModeTabContentsEvent event) { + if (event.getTabKey().equals(CreativeModeTabs.REDSTONE_BLOCKS)) { + event.accept(GItems.TERMINAL); + event.accept(GItems.PROJECTOR); + event.accept(GItems.REDSTONE_INFUSED_SAND); + event.accept(GItems.POWERABLE_GLASS); + } + } + + private static void serverTick(ServerTickEvent.Post event) { + RemoteSceneServerManager.tick(event.getServer()); + } + + private static void playerJoined(PlayerEvent.PlayerLoggedInEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + GNetworking.sendSnapshot(player); + } + } + + private static void playerLeft(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + RemoteSceneServerManager.removePlayer(player.level().getServer(), player, false); + } + } + + private static void playerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + RemoteSceneServerManager.removePlayer(player.level().getServer(), player, true); + GNetworking.sendSnapshot(player); + } + } + + private static void levelLoaded(LevelEvent.Load event) { + if (event.getLevel() instanceof ServerLevel level) { + ChannelManagerPersistence.onLevelLoad(level); + } + } + + private static void levelUnloaded(LevelEvent.Unload event) { + if (event.getLevel() instanceof ServerLevel level) { + RemoteSceneServerManager.unloadWorld(level.getServer(), level); + } + } + + private static void serverStopping(ServerStoppingEvent event) { + RemoteSceneServerManager.stop(event.getServer()); + } +} +*///?} diff --git a/glass/src/main/java/dev/imb11/neoforge/GlassNeoForgeClient.java b/glass/src/main/java/dev/imb11/neoforge/GlassNeoForgeClient.java new file mode 100644 index 0000000..e65c548 --- /dev/null +++ b/glass/src/main/java/dev/imb11/neoforge/GlassNeoForgeClient.java @@ -0,0 +1,68 @@ +//? neoforge { +/*package dev.imb11.neoforge; + +import dev.imb11.Glass; +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.client.GlassClient; +import dev.imb11.client.gui.ProjectorBlockGUI; +import dev.imb11.client.gui.ProjectorBlockScreen; +import dev.imb11.client.gui.TerminalBlockGUI; +import dev.imb11.client.gui.TerminalBlockScreen; +import dev.imb11.client.renderer.block.ProjectorBlockEntityRenderer; +import dev.imb11.client.renderer.block.TerminalBlockEntityRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.EntityRenderersEvent; +import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.ChunkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; + + +@Mod(value = Glass.MOD_ID, dist = Dist.CLIENT) +public final class GlassNeoForgeClient { + public GlassNeoForgeClient(IEventBus modBus) { + GlassClient.initialize(); + modBus.addListener(GlassNeoForgeClient::registerRenderers); + modBus.addListener(GlassNeoForgeClient::registerScreens); + NeoForge.EVENT_BUS.addListener(GlassNeoForgeClient::tick); + NeoForge.EVENT_BUS.addListener(GlassNeoForgeClient::loggingIn); + NeoForge.EVENT_BUS.addListener(GlassNeoForgeClient::loggingOut); + NeoForge.EVENT_BUS.addListener(GlassNeoForgeClient::chunkUnloaded); + } + + private static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) { + event.registerBlockEntityRenderer(TerminalBlockEntity.BLOCK_ENTITY_TYPE, TerminalBlockEntityRenderer::new); + } + + private static void registerScreens(RegisterMenuScreensEvent event) { + event.register(TerminalBlockGUI.SCREEN_HANDLER_TYPE, TerminalBlockScreen::new); + event.register(ProjectorBlockGUI.SCREEN_HANDLER_TYPE, ProjectorBlockScreen::new); + } + + private static void tick(ClientTickEvent.Pre event) { + GlassClient.tick(Minecraft.getInstance()); + } + + private static void loggingIn(ClientPlayerNetworkEvent.LoggingIn event) { + GlassClient.clearConnection(); + } + + private static void loggingOut(ClientPlayerNetworkEvent.LoggingOut event) { + GlassClient.clearConnection(); + } + + private static void chunkUnloaded(ChunkEvent.Unload event) { + if (event.getLevel() instanceof ClientLevel level && event.getChunk() instanceof LevelChunk chunk) { + GlassClient.onChunkUnload(level, chunk); + } + } +} +*///?} diff --git a/glass/src/main/java/dev/imb11/platform/ClientNetworking.java b/glass/src/main/java/dev/imb11/platform/ClientNetworking.java new file mode 100644 index 0000000..90b5f69 --- /dev/null +++ b/glass/src/main/java/dev/imb11/platform/ClientNetworking.java @@ -0,0 +1,51 @@ +//? if fabric { +package dev.imb11.platform; + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +import java.util.function.Consumer; + +public final class ClientNetworking { + private ClientNetworking() { + } + + public static void registerReceiver(CustomPacketPayload.Type type, Consumer handler) { + ClientPlayNetworking.registerGlobalReceiver(type, (packet, context) -> handler.accept(packet)); + } + + public static boolean canSend(CustomPacketPayload.Type type) { + return ClientPlayNetworking.canSend(type); + } + + public static void send(CustomPacketPayload payload) { + ClientPlayNetworking.send(payload); + } +} +//?} else { +/*package dev.imb11.platform; + +import net.minecraft.client.Minecraft; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; + +import java.util.function.Consumer; + +public final class ClientNetworking { + private ClientNetworking() { + } + + public static void registerReceiver(CustomPacketPayload.Type type, Consumer handler) { + PlatformNetworking.registerClientHandler(type, handler); + } + + public static boolean canSend(CustomPacketPayload.Type type) { + var connection = Minecraft.getInstance().getConnection(); + return connection != null && connection.hasChannel(type.id()); + } + + public static void send(CustomPacketPayload payload) { + ClientPacketDistributor.sendToServer(payload); + } +} +*///?} diff --git a/glass/src/main/java/dev/imb11/platform/ExtendedMenuProvider.java b/glass/src/main/java/dev/imb11/platform/ExtendedMenuProvider.java new file mode 100644 index 0000000..301a3c2 --- /dev/null +++ b/glass/src/main/java/dev/imb11/platform/ExtendedMenuProvider.java @@ -0,0 +1,23 @@ +//? if fabric { +package dev.imb11.platform; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; + +public interface ExtendedMenuProvider extends net.fabricmc.fabric.api.menu.v1.ExtendedMenuProvider { + StreamCodec getScreenOpeningCodec(); +} +//?} else { +/*package dev.imb11.platform; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.MenuProvider; + +public interface ExtendedMenuProvider extends MenuProvider { + T getScreenOpeningData(ServerPlayer player); + + StreamCodec getScreenOpeningCodec(); +} +*///?} diff --git a/glass/src/main/java/dev/imb11/platform/MenuFactory.java b/glass/src/main/java/dev/imb11/platform/MenuFactory.java new file mode 100644 index 0000000..1282e91 --- /dev/null +++ b/glass/src/main/java/dev/imb11/platform/MenuFactory.java @@ -0,0 +1,9 @@ +package dev.imb11.platform; + +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.AbstractContainerMenu; + +@FunctionalInterface +public interface MenuFactory { + T create(int id, Inventory inventory, D data); +} diff --git a/glass/src/main/java/dev/imb11/platform/Platform.java b/glass/src/main/java/dev/imb11/platform/Platform.java new file mode 100644 index 0000000..419f222 --- /dev/null +++ b/glass/src/main/java/dev/imb11/platform/Platform.java @@ -0,0 +1,64 @@ +//? if fabric { +package dev.imb11.platform; + +import net.fabricmc.fabric.api.menu.v1.ExtendedMenuType; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; + +public final class Platform { + private Platform() { + } + + public static boolean isModLoaded(String id) { + return FabricLoader.getInstance().isModLoaded(id); + } + + public static void openMenu(Player player, ExtendedMenuProvider provider) { + if (provider != null) { + player.openMenu(provider); + } + } + + public static MenuType menuType(MenuFactory factory, StreamCodec codec) { + return new ExtendedMenuType<>(factory::create, codec); + } +} +//?} else { +/*package dev.imb11.platform; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.MenuType; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.common.extensions.IMenuTypeExtension; + +public final class Platform { + private Platform() { + } + + public static boolean isModLoaded(String id) { + return ModList.get().isLoaded(id); + } + + public static void openMenu(Player player, ExtendedMenuProvider provider) { + if (player instanceof ServerPlayer serverPlayer && provider != null) { + serverPlayer.openMenu(provider, buffer -> provider.getScreenOpeningCodec().encode( + new RegistryFriendlyByteBuf(buffer, serverPlayer.registryAccess()), + provider.getScreenOpeningData(serverPlayer) + )); + } + } + + public static MenuType menuType(MenuFactory factory, StreamCodec codec) { + return IMenuTypeExtension.create((id, inventory, buffer) -> factory.create(id, inventory, + codec.decode(new RegistryFriendlyByteBuf(buffer, inventory.player.registryAccess())))); + } +} +*///?} diff --git a/glass/src/main/java/dev/imb11/platform/PlatformBlockEntity.java b/glass/src/main/java/dev/imb11/platform/PlatformBlockEntity.java new file mode 100644 index 0000000..4c9016e --- /dev/null +++ b/glass/src/main/java/dev/imb11/platform/PlatformBlockEntity.java @@ -0,0 +1,67 @@ +//? if fabric { +package dev.imb11.platform; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +public abstract class PlatformBlockEntity extends BlockEntity { + protected PlatformBlockEntity(BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } +} +//?} else { +/*package dev.imb11.platform; + +import dev.imb11.client.GlassClient; +import dev.imb11.sync.ChannelManagerPersistence; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; + +public abstract class PlatformBlockEntity extends BlockEntity { + protected PlatformBlockEntity(BlockEntityType type, BlockPos pos, BlockState state) { + super(type, pos, state); + } + + @Override + public void onLoad() { + super.onLoad(); + if (level instanceof ServerLevel serverLevel) { + ChannelManagerPersistence.onBlockEntityLoad(this, serverLevel); + } else if (level != null && level.isClientSide()) { + ClientHooks.loaded(this); + } + } + + @Override + public void setRemoved() { + if (level != null && level.isClientSide()) { + ClientHooks.unloaded(this); + } + super.setRemoved(); + } + + @Override + public void onChunkUnloaded() { + if (level != null && level.isClientSide()) { + ClientHooks.unloaded(this); + } + super.onChunkUnloaded(); + } + + private static final class ClientHooks { + private static void loaded(BlockEntity blockEntity) { + GlassClient.onBlockEntityLoad(blockEntity, (ClientLevel) blockEntity.getLevel()); + } + + private static void unloaded(BlockEntity blockEntity) { + GlassClient.onBlockEntityUnload(blockEntity, (ClientLevel) blockEntity.getLevel()); + } + } +} +*///?} diff --git a/glass/src/main/java/dev/imb11/platform/PlatformNetworking.java b/glass/src/main/java/dev/imb11/platform/PlatformNetworking.java new file mode 100644 index 0000000..b8e253c --- /dev/null +++ b/glass/src/main/java/dev/imb11/platform/PlatformNetworking.java @@ -0,0 +1,121 @@ +//? if fabric { +package dev.imb11.platform; + +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.PlayerLookup; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.Packet; +import net.minecraft.server.level.ServerEntity; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.Collection; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public final class PlatformNetworking { + private PlatformNetworking() { + } + + public static void registerServerbound(CustomPacketPayload.Type type, StreamCodec codec, BiConsumer handler) { + PayloadTypeRegistry.serverboundPlay().register(type, codec); + ServerPlayNetworking.registerGlobalReceiver(type, (packet, context) -> handler.accept(packet, context.player())); + } + + public static void registerClientbound(CustomPacketPayload.Type type, StreamCodec codec) { + PayloadTypeRegistry.clientboundPlay().register(type, codec); + } + + public static boolean canSend(ServerPlayer player, CustomPacketPayload.Type type) { + return ServerPlayNetworking.canSend(player, type); + } + + public static void send(ServerPlayer player, CustomPacketPayload payload) { + ServerPlayNetworking.send(player, payload); + } + + public static Collection tracking(ServerLevel level, BlockPos pos) { + return PlayerLookup.tracking(level, pos); + } + + public static void sendPairingData(ServerEntity entity, ServerPlayer player, Consumer> consumer) { + entity.sendPairingData(player, consumer::accept); + } +} +//?} else { +/*package dev.imb11.platform; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.Packet; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerEntity; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public final class PlatformNetworking { + private static final List> REGISTRATIONS = new ArrayList<>(); + private static final Map> CLIENT_HANDLERS = new HashMap<>(); + + private PlatformNetworking() { + } + + public static void registerServerbound(CustomPacketPayload.Type type, StreamCodec codec, BiConsumer handler) { + REGISTRATIONS.add(registrar -> registrar.playToServer(type, codec, (packet, context) -> + handler.accept(packet, (ServerPlayer) context.player()))); + } + + public static void registerClientbound(CustomPacketPayload.Type type, StreamCodec codec) { + REGISTRATIONS.add(registrar -> registrar.playToClient(type, codec, (packet, context) -> { + Consumer handler = CLIENT_HANDLERS.get(type.id()); + if (handler != null) { + handler.accept(packet); + } + })); + } + + public static void registerPayloads(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar("1"); + REGISTRATIONS.forEach(registration -> registration.accept(registrar)); + } + + @SuppressWarnings("unchecked") + public static void registerClientHandler(CustomPacketPayload.Type type, Consumer handler) { + CLIENT_HANDLERS.put(type.id(), packet -> handler.accept((T) packet)); + } + + public static boolean canSend(ServerPlayer player, CustomPacketPayload.Type type) { + return player.connection.hasChannel(type.id()); + } + + public static void send(ServerPlayer player, CustomPacketPayload payload) { + PacketDistributor.sendToPlayer(player, payload); + } + + public static Collection tracking(ServerLevel level, BlockPos pos) { + return level.getChunkSource().chunkMap.getPlayers(ChunkPos.containing(pos), false); + } + + public static void sendPairingData(ServerEntity entity, ServerPlayer player, Consumer> consumer) { + entity.sendPairingData(player, consumer::accept); + } +} +*///?} diff --git a/glass/src/main/java/dev/imb11/projection/ProjectionChunkRegion.java b/glass/src/main/java/dev/imb11/projection/ProjectionChunkRegion.java new file mode 100644 index 0000000..46d1673 --- /dev/null +++ b/glass/src/main/java/dev/imb11/projection/ProjectionChunkRegion.java @@ -0,0 +1,34 @@ +package dev.imb11.projection; + +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.phys.Vec3; + +import java.util.LinkedHashSet; +import java.util.Set; + +public record ProjectionChunkRegion(ChunkPos center, int radius) { + public static final int NEIGHBOR_PADDING = 1; + + public static ChunkPos cameraCenter(Vec3 position) { + return new ChunkPos(SectionPos.posToSectionCoord(position.x), SectionPos.posToSectionCoord(position.z)); + } + + public boolean contains(int x, int z) { + return Math.abs((long) x - center.x()) <= radius && Math.abs((long) z - center.z()) <= radius; + } + + public ProjectionChunkRegion withNeighbors() { + return new ProjectionChunkRegion(center, radius + NEIGHBOR_PADDING); + } + + public Set chunks() { + Set chunks = new LinkedHashSet<>(); + for (int x = center.x() - radius; x <= center.x() + radius; x++) { + for (int z = center.z() - radius; z <= center.z() + radius; z++) { + chunks.add(new ChunkPos(x, z)); + } + } + return chunks; + } +} diff --git a/glass/src/main/java/dev/imb11/projection/ProjectionSurface.java b/glass/src/main/java/dev/imb11/projection/ProjectionSurface.java new file mode 100644 index 0000000..055a3d8 --- /dev/null +++ b/glass/src/main/java/dev/imb11/projection/ProjectionSurface.java @@ -0,0 +1,368 @@ +package dev.imb11.projection; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.phys.AABB; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; + +public final class ProjectionSurface { + public static final int REVEAL_EDGE_WIDTH = 2; + public static final int MAX_RADIUS = 64; + public static final int MAX_FACES = 100; + + private static final double BOUNDS_INFLATION = 0.01D; + private static final Comparator FACE_KEY_COMPARATOR = Comparator + .comparingInt((FaceKey key) -> key.position().getX()) + .thenComparingInt(key -> key.position().getY()) + .thenComparingInt(key -> key.position().getZ()) + .thenComparingInt(key -> key.normal().get3DDataValue()); + + private final BlockPos origin; + private final Direction facing; + private final Direction uDirection; + private final Direction vDirection; + private final AABB renderBounds; + private final List faces; + private final Map dependencies; + private final int furthestRevealDistance; + private final boolean truncated; + + private ProjectionSurface( + BlockPos origin, + Direction facing, + Direction uDirection, + Direction vDirection, + AABB renderBounds, + List faces, + Map dependencies, + int furthestRevealDistance, + boolean truncated + ) { + this.origin = origin; + this.facing = facing; + this.uDirection = uDirection; + this.vDirection = vDirection; + this.renderBounds = renderBounds; + this.faces = faces; + this.dependencies = dependencies; + this.furthestRevealDistance = furthestRevealDistance; + this.truncated = truncated; + } + + public static ProjectionSurface rebuild( + BlockPos origin, + Direction facing, + BlockQuery query + ) { + Objects.requireNonNull(origin); + Objects.requireNonNull(facing); + Objects.requireNonNull(query); + + BlockPos immutableOrigin = origin.immutable(); + FaceKey root = new FaceKey(immutableOrigin, facing); + DependencyCollector dependencyCollector = new DependencyCollector(query); + Map distances = new HashMap<>(); + distances.put(root, 0); + List layer = List.of(root); + boolean truncated = false; + + while (!layer.isEmpty()) { + TreeSet nextKeys = new TreeSet<>(FACE_KEY_COMPARATOR); + for (FaceKey current : layer) { + for (FaceKey candidate : expand(current, root, dependencyCollector)) { + if (distances.containsKey(candidate) || nextKeys.contains(candidate)) { + continue; + } + if (!withinRadius(immutableOrigin, candidate.position())) { + truncated = true; + continue; + } + nextKeys.add(candidate); + } + } + + if (nextKeys.isEmpty()) { + break; + } + + int remaining = MAX_FACES - distances.size(); + if (remaining <= 0) { + truncated = true; + break; + } + + int nextDistance = distances.get(layer.getFirst()) + 1; + List nextLayer = new ArrayList<>(Math.min(remaining, nextKeys.size())); + for (FaceKey nextKey : nextKeys) { + if (nextLayer.size() >= remaining) { + truncated = true; + break; + } + distances.put(nextKey, nextDistance); + nextLayer.add(nextKey); + } + layer = List.copyOf(nextLayer); + } + + List> sortedEntries = new ArrayList<>(distances.entrySet()); + sortedEntries.sort(Map.Entry.comparingByKey(FACE_KEY_COMPARATOR)); + List faces = new ArrayList<>(sortedEntries.size()); + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxY = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + int furthestRevealDistance = 0; + + for (Map.Entry entry : sortedEntries) { + FaceKey key = entry.getKey(); + BlockPos position = key.position(); + Direction normal = key.normal(); + int revealDistance = entry.getValue(); + faces.add(new Face( + position, + normal, + uDirection(normal), + vDirection(normal), + revealDistance, + key.equals(root) + )); + minX = Math.min(minX, position.getX()); + minY = Math.min(minY, position.getY()); + minZ = Math.min(minZ, position.getZ()); + maxX = Math.max(maxX, position.getX() + 1); + maxY = Math.max(maxY, position.getY() + 1); + maxZ = Math.max(maxZ, position.getZ() + 1); + furthestRevealDistance = Math.max(furthestRevealDistance, revealDistance); + } + + List immutableFaces = List.copyOf(faces); + AABB renderBounds = new AABB(minX, minY, minZ, maxX, maxY, maxZ).inflate(BOUNDS_INFLATION); + return new ProjectionSurface( + immutableOrigin, + facing, + uDirection(facing), + vDirection(facing), + renderBounds, + immutableFaces, + dependencyCollector.snapshot(), + furthestRevealDistance, + truncated + ); + } + + public boolean isTopologyValid(Direction facing, BlockQuery query) { + Objects.requireNonNull(facing); + Objects.requireNonNull(query); + if (this.facing != facing) { + return false; + } + for (Map.Entry dependency : dependencies.entrySet()) { + if (Objects.requireNonNull(query.sample(dependency.getKey())) != dependency.getValue()) { + return false; + } + } + return true; + } + + public BlockPos origin() { + return origin; + } + + public Direction facing() { + return facing; + } + + public Direction uDirection() { + return uDirection; + } + + public Direction vDirection() { + return vDirection; + } + + public AABB renderBounds() { + return renderBounds; + } + + public List faces() { + return faces; + } + + public int furthestRevealDistance() { + return furthestRevealDistance; + } + + public int completedRevealDistance() { + return furthestRevealDistance + REVEAL_EDGE_WIDTH; + } + + public boolean truncated() { + return truncated; + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof ProjectionSurface surface + && origin.equals(surface.origin) + && facing == surface.facing + && truncated == surface.truncated + && faces.equals(surface.faces); + } + + @Override + public int hashCode() { + return Objects.hash(origin, facing, truncated, faces); + } + + private static List expand( + FaceKey current, + FaceKey root, + DependencyCollector dependencies + ) { + List candidates = new ArrayList<>(4); + BlockPos position = current.position(); + Direction normal = current.normal(); + Direction[] edgeDirections = edgeDirections(normal); + + if (current.equals(root)) { + for (Direction edgeDirection : edgeDirections) { + BlockPos sidePosition = position.relative(edgeDirection); + if (dependencies.sample(sidePosition) != BlockSample.GLASS) { + continue; + } + BlockPos diagonalPosition = sidePosition.relative(normal); + if (dependencies.sample(diagonalPosition) == BlockSample.OTHER) { + candidates.add(new FaceKey(sidePosition, normal)); + } + } + return candidates; + } + + if (dependencies.sample(position) != BlockSample.GLASS + || dependencies.sample(position.relative(normal)) != BlockSample.OTHER) { + return candidates; + } + + for (Direction edgeDirection : edgeDirections) { + BlockPos sidePosition = position.relative(edgeDirection); + BlockSample side = dependencies.sample(sidePosition); + if (side == BlockSample.GLASS) { + BlockPos diagonalPosition = sidePosition.relative(normal); + BlockSample diagonal = dependencies.sample(diagonalPosition); + if (diagonal == BlockSample.OTHER) { + candidates.add(new FaceKey(sidePosition, normal)); + } else if (diagonal == BlockSample.GLASS) { + candidates.add(new FaceKey(diagonalPosition, edgeDirection.getOpposite())); + } + } else if (side == BlockSample.OTHER) { + BlockPos diagonalPosition = sidePosition.relative(normal); + if (dependencies.sample(diagonalPosition) == BlockSample.OTHER + && dependencies.sample(position.relative(normal.getOpposite())) == BlockSample.GLASS) { + candidates.add(new FaceKey(position, edgeDirection)); + } + } + } + return candidates; + } + + private static Direction[] edgeDirections(Direction normal) { + Direction uDirection = uDirection(normal); + Direction vDirection = vDirection(normal); + return new Direction[]{ + uDirection, + uDirection.getOpposite(), + vDirection, + vDirection.getOpposite() + }; + } + + private static boolean withinRadius(BlockPos origin, BlockPos position) { + return Math.abs(position.getX() - origin.getX()) <= MAX_RADIUS + && Math.abs(position.getY() - origin.getY()) <= MAX_RADIUS + && Math.abs(position.getZ() - origin.getZ()) <= MAX_RADIUS; + } + + private static Direction uDirection(Direction facing) { + return switch (facing) { + case WEST -> Direction.SOUTH; + case EAST -> Direction.NORTH; + case SOUTH, DOWN, UP -> Direction.EAST; + case NORTH -> Direction.WEST; + }; + } + + private static Direction vDirection(Direction facing) { + return switch (facing) { + case WEST, EAST, SOUTH, NORTH -> Direction.UP; + case DOWN -> Direction.SOUTH; + case UP -> Direction.NORTH; + }; + } + + @FunctionalInterface + public interface BlockQuery { + BlockSample sample(BlockPos position); + } + + public enum BlockSample { + GLASS, + OTHER, + UNLOADED + } + + public record Face( + BlockPos position, + Direction normal, + Direction uDirection, + Direction vDirection, + int revealDistance, + boolean seed + ) { + public Face { + position = position.immutable(); + Objects.requireNonNull(normal); + Objects.requireNonNull(uDirection); + Objects.requireNonNull(vDirection); + } + } + + private record FaceKey(BlockPos position, Direction normal) { + private FaceKey { + position = position.immutable(); + Objects.requireNonNull(normal); + } + } + + private static final class DependencyCollector { + private final BlockQuery query; + private final Map dependencies = new HashMap<>(); + + private DependencyCollector(BlockQuery query) { + this.query = query; + } + + private BlockSample sample(BlockPos position) { + BlockPos immutablePosition = position.immutable(); + BlockSample existing = dependencies.get(immutablePosition); + if (existing != null) { + return existing; + } + BlockSample sampled = Objects.requireNonNull(query.sample(immutablePosition)); + dependencies.put(immutablePosition, sampled); + return sampled; + } + + private Map snapshot() { + return Map.copyOf(dependencies); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sounds/GSounds.java b/glass/src/main/java/dev/imb11/sounds/GSounds.java new file mode 100644 index 0000000..1af738b --- /dev/null +++ b/glass/src/main/java/dev/imb11/sounds/GSounds.java @@ -0,0 +1,26 @@ +package dev.imb11.sounds; + +import dev.imb11.Glass; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; + +public final class GSounds { + private static final Identifier PROJECTION_PANEL_ACTIVATE_ID = + Identifier.fromNamespaceAndPath(Glass.MOD_ID, "projection.panel_activate"); + public static final SoundEvent PROJECTION_PANEL_ACTIVATE = + SoundEvent.createVariableRangeEvent(PROJECTION_PANEL_ACTIVATE_ID); + private static final Identifier PROJECTION_PANEL_DEACTIVATE_ID = + Identifier.fromNamespaceAndPath(Glass.MOD_ID, "projection.panel_deactivate"); + public static final SoundEvent PROJECTION_PANEL_DEACTIVATE = + SoundEvent.createVariableRangeEvent(PROJECTION_PANEL_DEACTIVATE_ID); + + private GSounds() { + } + + public static void init() { + Registry.register(BuiltInRegistries.SOUND_EVENT, PROJECTION_PANEL_ACTIVATE_ID, PROJECTION_PANEL_ACTIVATE); + Registry.register(BuiltInRegistries.SOUND_EVENT, PROJECTION_PANEL_DEACTIVATE_ID, PROJECTION_PANEL_DEACTIVATE); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/Channel.java b/glass/src/main/java/dev/imb11/sync/Channel.java new file mode 100644 index 0000000..45997d5 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/Channel.java @@ -0,0 +1,19 @@ +package dev.imb11.sync; + +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public record Channel(String name, @Nullable ProjectionSource source) { + public Channel { + name = Objects.requireNonNull(name); + if (source != null && !name.equals(source.channel())) { + throw new IllegalArgumentException("source channel does not match channel name"); + } + } + + @Override + public String toString() { + return "{Name:" + name + ",Source:" + source + "}"; + } +} diff --git a/glass/src/main/java/dev/imb11/sync/ChannelManagerPersistence.java b/glass/src/main/java/dev/imb11/sync/ChannelManagerPersistence.java new file mode 100644 index 0000000..442eba6 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/ChannelManagerPersistence.java @@ -0,0 +1,535 @@ +package dev.imb11.sync; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.TerminalBlock; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.sync.packets.S2CChannelSnapshotPacket; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.TickTask; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; +import org.jetbrains.annotations.Nullable; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class ChannelManagerPersistence extends SavedData { + public static final int MAX_CHANNEL_NAME_LENGTH = 48; + public static final int MAX_WIRE_CHANNEL_NAME_LENGTH = 64; + public static final int MAX_CHANNELS = 256; + public static final String DEFAULT_CHANNEL = "Default"; + public static final SavedDataType TYPE = new SavedDataType<>(Identifier.withDefaultNamespace("glass_channels"), ChannelManagerPersistence::new, CompoundTag.CODEC.xmap(tag -> gather(tag, null), data -> data.save(new CompoundTag(), null)), null); + private static final SavedDataType LEGACY_TYPE = new SavedDataType<>(Identifier.withDefaultNamespace("glass_channels"), LegacyChannelData::new, CompoundTag.CODEC.xmap(tag -> LegacyChannelData.gather(tag, null), data -> data.save(new CompoundTag(), null)), null); + + private final Map channels = new LinkedHashMap<>(); + private final Map pendingLinks = new LinkedHashMap<>(); + private final Set> importedLegacyDimensions = new LinkedHashSet<>(); + private long registryRevision; + private transient MinecraftServer server; + private final Map projectionActivity = new HashMap<>(); + + public static ChannelManagerPersistence get(Level level) { + if (!(level instanceof ServerLevel serverLevel)) { + throw new IllegalArgumentException("Channel state is server-only"); + } + return get(serverLevel.getServer()); + } + + public static ChannelManagerPersistence get(MinecraftServer server) { + ChannelManagerPersistence persistence = server.overworld().getDataStorage().computeIfAbsent(TYPE); + persistence.server = server; + persistence.ensureDefaultChannel(); + return persistence; + } + + public static void onLevelLoad(ServerLevel world) { + MinecraftServer server = world.getServer(); + defer(server, () -> { + if (server.overworld() != null) { + get(server).importLegacy(world); + } + }); + } + + public static void onBlockEntityLoad(BlockEntity blockEntity, ServerLevel world) { + if (blockEntity instanceof TerminalBlockEntity terminal) { + defer(world.getServer(), () -> { + if (world.hasChunkAt(terminal.getBlockPos()) && world.getBlockEntity(terminal.getBlockPos()) == terminal) { + get(world).reconcileTerminal(terminal); + } + }); + } + } + + public long registryRevision() { + return registryRevision; + } + + public List snapshot() { + return List.copyOf(channels.values()); + } + + public S2CChannelSnapshotPacket snapshotPacket() { + return new S2CChannelSnapshotPacket(registryRevision, snapshot()); + } + + public Optional channel(String name) { + String canonical = canonicalChannelName(name); + return canonical == null ? Optional.empty() : Optional.ofNullable(channels.get(canonical)); + } + + public Optional resolve(String name) { + return channel(name).map(Channel::source); + } + + public void recordProjection(String name) { + if (server != null) { + resolve(name).ifPresent(source -> projectionActivity.put(source.channel(), + new ProjectionActivity(source, server.getTickCount()))); + } + } + + public boolean isProjecting(ServerLevel level, BlockPos pos, String name) { + ProjectionActivity activity = projectionActivity.get(name); + if (server == null || activity == null + || !sameLocation(activity.source().dimension(), activity.source().pos(), level.dimension(), pos)) { + return false; + } + int age = server.getTickCount() - activity.tick(); + return age >= 0 && age <= 2; + } + + public boolean containsName(String name) { + return channel(name).isPresent(); + } + + public boolean createChannel(String name) { + String canonical = canonicalChannelName(name); + if (canonical == null || channels.containsKey(canonical) || channels.size() >= MAX_CHANNELS) { + return false; + } + channels.put(canonical, new Channel(canonical, null)); + commit(nextRevision()); + return true; + } + + public boolean deleteChannel(String name) { + String canonical = canonicalChannelName(name); + if (canonical == null || DEFAULT_CHANNEL.equals(canonical)) { + return false; + } + Channel removed = channels.remove(canonical); + if (removed == null) { + return false; + } + PendingLink pending = pendingLinks.remove(canonical); + commit(nextRevision()); + if (removed.source() != null) { + clearLoadedTerminal(removed.source().dimension(), removed.source().pos(), canonical); + } + if (pending != null) { + clearLoadedTerminal(pending.dimension(), pending.pos(), canonical); + } + return true; + } + + public boolean claimTerminal(ServerLevel level, BlockPos pos, Direction facing, String name) { + String canonical = canonicalChannelName(name); + if (canonical == null || !channels.containsKey(canonical)) { + return false; + } + + BlockPos immutablePos = pos.immutable(); + Channel selected = channels.get(canonical); + ProjectionSource current = selected.source(); + if (current != null && !sameLocation(current.dimension(), current.pos(), level.dimension(), immutablePos)) { + return false; + } + PendingLink selectedPending = pendingLinks.get(canonical); + if (selectedPending != null && !sameLocation(selectedPending.dimension(), selectedPending.pos(), level.dimension(), immutablePos)) { + return false; + } + + boolean changed = false; + + for (Map.Entry entry : channels.entrySet()) { + ProjectionSource source = entry.getValue().source(); + if (source != null && sameLocation(source.dimension(), source.pos(), level.dimension(), immutablePos) && !entry.getKey().equals(canonical)) { + entry.setValue(new Channel(entry.getKey(), null)); + changed = true; + } + } + + boolean sourceMatches = current != null + && sameLocation(current.dimension(), current.pos(), level.dimension(), immutablePos) + && current.facing() == facing; + if (!sourceMatches) { + changed = true; + } + + if (pendingLinks.entrySet().removeIf(entry -> entry.getKey().equals(canonical) + || sameLocation(entry.getValue().dimension(), entry.getValue().pos(), level.dimension(), immutablePos))) { + changed = true; + } + + if (!changed) { + return true; + } + + long revision = nextRevision(); + channels.put(canonical, new Channel(canonical, new ProjectionSource(canonical, level.dimension(), immutablePos, facing, revision))); + commit(revision); + return true; + } + + public boolean unlinkTerminal(ServerLevel level, BlockPos pos) { + BlockPos immutablePos = pos.immutable(); + boolean changed = false; + + for (Map.Entry entry : channels.entrySet()) { + ProjectionSource source = entry.getValue().source(); + if (source != null && sameLocation(source.dimension(), source.pos(), level.dimension(), immutablePos)) { + entry.setValue(new Channel(entry.getKey(), null)); + changed = true; + } + } + if (pendingLinks.entrySet().removeIf(entry -> sameLocation(entry.getValue().dimension(), entry.getValue().pos(), level.dimension(), immutablePos))) { + changed = true; + } + if (changed) { + commit(nextRevision()); + } + return changed; + } + + public void reconcileTerminal(TerminalBlockEntity terminal) { + if (!(terminal.getLevel() instanceof ServerLevel level)) { + return; + } + BlockPos pos = terminal.getBlockPos(); + if (!level.hasChunkAt(pos)) { + return; + } + var state = level.getBlockState(pos); + if (!state.is(GBlocks.TERMINAL) || !state.hasProperty(TerminalBlock.FACING)) { + return; + } + + String desired = sourceChannelAt(level.dimension(), pos); + if (desired == null) { + desired = pendingChannelAt(level.dimension(), pos); + } + + if (desired == null) { + terminal.setChannelFromServer(""); + return; + } + + if (claimTerminal(level, pos, state.getValue(TerminalBlock.FACING), desired)) { + terminal.setChannelFromServer(desired); + } else { + terminal.setChannelFromServer(""); + } + } + + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registryLookup) { + tag.putLong("registry_revision", registryRevision); + ListTag channelTags = new ListTag(); + for (Channel channel : channels.values()) { + CompoundTag item = new CompoundTag(); + item.putString("name", channel.name()); + ProjectionSource source = channel.source(); + PendingLink pending = pendingLinks.get(channel.name()); + if (source != null) { + writeLocation(item, source.dimension(), source.pos()); + item.putString("linked_facing", source.facing().getSerializedName()); + item.putLong("source_revision", source.revision()); + } else if (pending != null) { + writeLocation(item, pending.dimension(), pending.pos()); + } + channelTags.add(item); + } + tag.put("channels", channelTags); + + ListTag imported = new ListTag(); + for (ResourceKey dimension : importedLegacyDimensions) { + imported.add(StringTag.valueOf(dimension.identifier().toString())); + } + tag.put("legacy_imported_dimensions", imported); + return tag; + } + + public static ChannelManagerPersistence gather(CompoundTag tag, HolderLookup.Provider registryLookup) { + ChannelManagerPersistence persistence = new ChannelManagerPersistence(); + persistence.registryRevision = Math.max(0L, tag.getLongOr("registry_revision", 0L)); + Map loadedChannels = new LinkedHashMap<>(); + Map loadedPending = new LinkedHashMap<>(); + ListTag channelTags = tag.getListOrEmpty("channels"); + int count = Math.min(channelTags.size(), MAX_CHANNELS); + for (int i = 0; i < count; i++) { + CompoundTag item = channelTags.getCompoundOrEmpty(i); + String name = canonicalChannelName(item.getStringOr("name", "")); + if (name == null) { + continue; + } + BlockPos pos = getFromIntArrayNBT("linked_pos", item); + ResourceKey dimension = readDimension(item, "linked_dimension"); + Direction facing = Direction.byName(item.getStringOr("linked_facing", "").toLowerCase(Locale.ROOT)); + if (pos != null && dimension != null && facing != null) { + long sourceRevision = Math.max(0L, item.getLongOr("source_revision", 0L)); + persistence.registryRevision = Math.max(persistence.registryRevision, sourceRevision); + loadedChannels.put(name, new Channel(name, new ProjectionSource(name, dimension, pos, facing, sourceRevision))); + loadedPending.remove(name); + } else { + loadedChannels.put(name, new Channel(name, null)); + if (pos != null && dimension != null) { + loadedPending.put(name, new PendingLink(dimension, pos)); + } else { + loadedPending.remove(name); + } + } + } + persistence.channels.putAll(loadedChannels); + persistence.pendingLinks.putAll(loadedPending); + + ListTag importedTags = tag.getListOrEmpty("legacy_imported_dimensions"); + for (int i = 0; i < importedTags.size(); i++) { + ResourceKey dimension = dimensionKey(Identifier.tryParse(importedTags.getStringOr(i, ""))); + if (dimension != null) { + persistence.importedLegacyDimensions.add(dimension); + } + } + return persistence; + } + + @Nullable + public static BlockPos getFromIntArrayNBT(String key, CompoundTag compound) { + int[] values = compound.getIntArray(key).orElseGet(() -> new int[0]); + return values.length < 3 ? null : new BlockPos(values[0], values[1], values[2]); + } + + @Nullable + public static String canonicalChannelName(@Nullable String value) { + if (value == null) { + return null; + } + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC).strip(); + if (normalized.isEmpty()) { + return null; + } + StringBuilder result = new StringBuilder(normalized.length()); + boolean previousWhitespace = false; + for (int i = 0; i < normalized.length(); i++) { + char character = normalized.charAt(i); + if (Character.isISOControl(character)) { + return null; + } + if (Character.isWhitespace(character)) { + if (!previousWhitespace) { + result.append(' '); + } + previousWhitespace = true; + } else { + result.append(character); + previousWhitespace = false; + } + } + String canonical = result.toString(); + return canonical.length() > MAX_CHANNEL_NAME_LENGTH ? null : canonical; + } + + private void ensureDefaultChannel() { + if (!channels.containsKey(DEFAULT_CHANNEL)) { + Map existing = new LinkedHashMap<>(channels); + channels.clear(); + channels.put(DEFAULT_CHANNEL, new Channel(DEFAULT_CHANNEL, null)); + channels.putAll(existing); + commit(nextRevision()); + } + } + + private void importLegacy(ServerLevel world) { + ResourceKey dimension = world.dimension(); + if (importedLegacyDimensions.contains(dimension)) { + return; + } + if (dimension.equals(Level.OVERWORLD)) { + markLegacyImported(dimension); + return; + } + + LegacyChannelData legacy = world.getDataStorage().computeIfAbsent(LEGACY_TYPE); + boolean registryChanged = false; + for (LegacyEntry entry : legacy.entries()) { + if (!channels.containsKey(entry.name()) && channels.size() < MAX_CHANNELS) { + channels.put(entry.name(), new Channel(entry.name(), null)); + registryChanged = true; + } + Channel channel = channels.get(entry.name()); + if (entry.pos() != null && channel != null && channel.source() == null && !pendingLinks.containsKey(entry.name())) { + pendingLinks.put(entry.name(), new PendingLink(dimension, entry.pos())); + registryChanged = true; + } + } + + markLegacyImported(dimension); + if (registryChanged) { + commit(nextRevision()); + } + } + + private void markLegacyImported(ResourceKey dimension) { + if (importedLegacyDimensions.add(dimension)) { + setDirty(); + } + } + + private void commit(long revision) { + projectionActivity.entrySet().removeIf(entry -> { + Channel channel = channels.get(entry.getKey()); + return channel == null || !entry.getValue().source().equals(channel.source()); + }); + registryRevision = revision; + setDirty(); + if (server != null) { + GNetworking.broadcastSnapshot(server, this); + } + } + + private long nextRevision() { + return registryRevision == Long.MAX_VALUE ? Long.MAX_VALUE : registryRevision + 1L; + } + + private String sourceChannelAt(ResourceKey dimension, BlockPos pos) { + for (Channel channel : channels.values()) { + ProjectionSource source = channel.source(); + if (source != null && sameLocation(source.dimension(), source.pos(), dimension, pos)) { + return channel.name(); + } + } + return null; + } + + private String pendingChannelAt(ResourceKey dimension, BlockPos pos) { + for (Map.Entry entry : pendingLinks.entrySet()) { + PendingLink pending = entry.getValue(); + Channel channel = channels.get(entry.getKey()); + if (channel != null && channel.source() == null && sameLocation(pending.dimension(), pending.pos(), dimension, pos)) { + return entry.getKey(); + } + } + return null; + } + + private void clearLoadedTerminal(ResourceKey dimension, BlockPos pos, String channel) { + if (server == null) { + return; + } + ServerLevel level = server.getLevel(dimension); + if (level == null || !level.hasChunkAt(pos)) { + return; + } + if (level.getBlockEntity(pos) instanceof TerminalBlockEntity terminal && channel.equals(terminal.getChannel())) { + terminal.setChannelFromServer(""); + } + } + + private static void writeLocation(CompoundTag tag, ResourceKey dimension, BlockPos pos) { + tag.putIntArray("linked_pos", new int[]{pos.getX(), pos.getY(), pos.getZ()}); + tag.putString("linked_dimension", dimension.identifier().toString()); + } + + @Nullable + private static ResourceKey readDimension(CompoundTag tag, String key) { + if (!tag.getString(key).isPresent()) { + return Level.OVERWORLD; + } + return dimensionKey(Identifier.tryParse(tag.getStringOr(key, ""))); + } + + @Nullable + private static ResourceKey dimensionKey(@Nullable Identifier location) { + return location == null ? null : ResourceKey.create(Registries.DIMENSION, location); + } + + private static boolean sameLocation(ResourceKey firstDimension, BlockPos firstPos, ResourceKey secondDimension, BlockPos secondPos) { + return firstDimension.equals(secondDimension) && firstPos.equals(secondPos); + } + + private static void defer(MinecraftServer server, Runnable runnable) { + server.schedule(new TickTask(server.getTickCount(), runnable)); + } + + private record PendingLink(ResourceKey dimension, BlockPos pos) { + private PendingLink { + pos = pos.immutable(); + } + } + + private record ProjectionActivity(ProjectionSource source, int tick) { + } + + private record LegacyEntry(String name, @Nullable BlockPos pos) { + private LegacyEntry { + if (pos != null) { + pos = pos.immutable(); + } + } + } + + private static final class LegacyChannelData extends SavedData { + private final List entries; + + private LegacyChannelData() { + entries = List.of(); + } + + private LegacyChannelData(List entries) { + this.entries = List.copyOf(entries); + } + + private List entries() { + return entries; + } + + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registryLookup) { + return tag; + } + + private static LegacyChannelData gather(CompoundTag tag, HolderLookup.Provider registryLookup) { + List entries = new ArrayList<>(); + ListTag channels = tag.getListOrEmpty("channels"); + int count = Math.min(channels.size(), MAX_CHANNELS); + for (int i = 0; i < count; i++) { + CompoundTag channel = channels.getCompoundOrEmpty(i); + String name = canonicalChannelName(channel.getStringOr("name", "")); + BlockPos pos = getFromIntArrayNBT("linked_pos", channel); + if (name != null) { + entries.add(new LegacyEntry(name, pos)); + } + } + return new LegacyChannelData(entries); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/GNetworking.java b/glass/src/main/java/dev/imb11/sync/GNetworking.java new file mode 100644 index 0000000..2455738 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/GNetworking.java @@ -0,0 +1,101 @@ +package dev.imb11.sync; + +import dev.imb11.blocks.GBlocks; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.client.gui.ProjectorBlockGUI; +import dev.imb11.client.gui.TerminalBlockGUI; +import dev.imb11.sync.packets.C2SCreateChannelPacket; +import dev.imb11.sync.packets.C2SDeleteChannelPacket; +import dev.imb11.sync.packets.C2SProjectorChannelChangedPacket; +import dev.imb11.sync.packets.C2SRemoveLinkedChannelPacket; +import dev.imb11.sync.packets.C2STerminalChannelChangedPacket; +import dev.imb11.sync.packets.S2CChannelSnapshotPacket; +import dev.imb11.sync.remote.C2SRemoteSubscribePacket; +import dev.imb11.sync.remote.C2SRemoteChunkAckPacket; +import dev.imb11.sync.remote.C2SRemoteUnsubscribePacket; +import dev.imb11.sync.remote.C2SRemoteUpdatePacket; +import dev.imb11.sync.remote.RemoteSceneServerManager; +import dev.imb11.sync.remote.S2CRemoteBlockUpdatesPacket; +import dev.imb11.sync.remote.S2CRemoteChunkPacket; +import dev.imb11.sync.remote.S2CRemoteEntitiesPacket; +import dev.imb11.sync.remote.S2CRemoteBlockEntitiesPacket; +import dev.imb11.sync.remote.S2CRemoteLightPacket; +import dev.imb11.sync.remote.S2CRemoteSubscriptionPacket; +import dev.imb11.sync.remote.S2CRemoteUnavailablePacket; +import dev.imb11.sync.remote.S2CRemoteUnloadPacket; +import dev.imb11.sync.remote.S2CRemoteWorldStatePacket; +import dev.imb11.platform.PlatformNetworking; +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +public final class GNetworking { + private static final double MAX_INTERACTION_DISTANCE_SQUARED = 64.0D; + + public static void initialize() { + PlatformNetworking.registerServerbound(C2SCreateChannelPacket.PACKET_ID, C2SCreateChannelPacket.PACKET_CODEC, C2SCreateChannelPacket::receive); + PlatformNetworking.registerServerbound(C2SDeleteChannelPacket.PACKET_ID, C2SDeleteChannelPacket.PACKET_CODEC, C2SDeleteChannelPacket::receive); + PlatformNetworking.registerServerbound(C2STerminalChannelChangedPacket.PACKET_ID, C2STerminalChannelChangedPacket.PACKET_CODEC, C2STerminalChannelChangedPacket::receive); + PlatformNetworking.registerServerbound(C2SRemoveLinkedChannelPacket.PACKET_ID, C2SRemoveLinkedChannelPacket.PACKET_CODEC, C2SRemoveLinkedChannelPacket::receive); + PlatformNetworking.registerServerbound(C2SProjectorChannelChangedPacket.PACKET_ID, C2SProjectorChannelChangedPacket.PACKET_CODEC, C2SProjectorChannelChangedPacket::receive); + PlatformNetworking.registerServerbound(C2SRemoteSubscribePacket.PACKET_ID, C2SRemoteSubscribePacket.PACKET_CODEC, (payload, player) -> RemoteSceneServerManager.subscribe(player, payload)); + PlatformNetworking.registerServerbound(C2SRemoteChunkAckPacket.PACKET_ID, C2SRemoteChunkAckPacket.PACKET_CODEC, (payload, player) -> RemoteSceneServerManager.acknowledge(player, payload)); + PlatformNetworking.registerServerbound(C2SRemoteUpdatePacket.PACKET_ID, C2SRemoteUpdatePacket.PACKET_CODEC, (payload, player) -> RemoteSceneServerManager.update(player, payload)); + PlatformNetworking.registerServerbound(C2SRemoteUnsubscribePacket.PACKET_ID, C2SRemoteUnsubscribePacket.PACKET_CODEC, (payload, player) -> RemoteSceneServerManager.unsubscribe(player, payload)); + PlatformNetworking.registerClientbound(S2CChannelSnapshotPacket.PACKET_ID, S2CChannelSnapshotPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteSubscriptionPacket.PACKET_ID, S2CRemoteSubscriptionPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteUnavailablePacket.PACKET_ID, S2CRemoteUnavailablePacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteChunkPacket.PACKET_ID, S2CRemoteChunkPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteEntitiesPacket.PACKET_ID, S2CRemoteEntitiesPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteBlockEntitiesPacket.PACKET_ID, S2CRemoteBlockEntitiesPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteUnloadPacket.PACKET_ID, S2CRemoteUnloadPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteBlockUpdatesPacket.PACKET_ID, S2CRemoteBlockUpdatesPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteLightPacket.PACKET_ID, S2CRemoteLightPacket.PACKET_CODEC); + PlatformNetworking.registerClientbound(S2CRemoteWorldStatePacket.PACKET_ID, S2CRemoteWorldStatePacket.PACKET_CODEC); + } + + public static void sendSnapshot(ServerPlayer player) { + if (PlatformNetworking.canSend(player, S2CChannelSnapshotPacket.PACKET_ID)) { + PlatformNetworking.send(player, ChannelManagerPersistence.get(player.level().getServer()).snapshotPacket()); + } + } + + public static void broadcastSnapshot(MinecraftServer server, ChannelManagerPersistence persistence) { + S2CChannelSnapshotPacket snapshot = persistence.snapshotPacket(); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (PlatformNetworking.canSend(player, S2CChannelSnapshotPacket.PACKET_ID)) { + PlatformNetworking.send(player, snapshot); + } + } + } + + public static boolean canUseTerminal(ServerPlayer player, BlockPos pos) { + if (!(player.containerMenu instanceof TerminalBlockGUI menu) || !menu.isFor(pos)) { + return false; + } + return validTarget(player, pos, true); + } + + public static boolean canUseProjector(ServerPlayer player, BlockPos pos) { + if (!(player.containerMenu instanceof ProjectorBlockGUI menu) || !menu.isFor(pos)) { + return false; + } + return validTarget(player, pos, false); + } + + private static boolean validTarget(ServerPlayer player, BlockPos pos, boolean terminal) { + var level = player.level(); + if (!level.hasChunkAt(pos) + || player.distanceToSqr(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D) > MAX_INTERACTION_DISTANCE_SQUARED) { + return false; + } + if (terminal) { + return level.getBlockState(pos).is(GBlocks.TERMINAL) && level.getBlockEntity(pos) instanceof TerminalBlockEntity; + } + return level.getBlockState(pos).is(GBlocks.PROJECTOR) && level.getBlockEntity(pos) instanceof ProjectorBlockEntity; + } + + private GNetworking() { + } +} diff --git a/glass/src/main/java/dev/imb11/sync/GPackets.java b/glass/src/main/java/dev/imb11/sync/GPackets.java new file mode 100644 index 0000000..e69de29 diff --git a/glass/src/main/java/dev/imb11/sync/ProjectionSource.java b/glass/src/main/java/dev/imb11/sync/ProjectionSource.java new file mode 100644 index 0000000..8d19951 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/ProjectionSource.java @@ -0,0 +1,32 @@ +package dev.imb11.sync; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; + +import java.util.Objects; + +public record ProjectionSource(String channel, ResourceKey dimension, BlockPos pos, Direction facing, long revision) { + public ProjectionSource { + channel = Objects.requireNonNull(channel); + dimension = Objects.requireNonNull(dimension); + pos = Objects.requireNonNull(pos).immutable(); + facing = Objects.requireNonNull(facing); + if (revision < 0L) { + throw new IllegalArgumentException("revision must be non-negative"); + } + } + + public Key key() { + return new Key(channel, dimension, pos); + } + + public record Key(String channel, ResourceKey dimension, BlockPos pos) { + public Key { + channel = Objects.requireNonNull(channel); + dimension = Objects.requireNonNull(dimension); + pos = Objects.requireNonNull(pos).immutable(); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/packets/C2SCreateChannelPacket.java b/glass/src/main/java/dev/imb11/sync/packets/C2SCreateChannelPacket.java new file mode 100644 index 0000000..b823894 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/packets/C2SCreateChannelPacket.java @@ -0,0 +1,29 @@ +package dev.imb11.sync.packets; + +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record C2SCreateChannelPacket(BlockPos pos, String channel) implements CustomPacketPayload { + public static final CustomPacketPayload.Type PACKET_ID = new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("glass", "create_channel")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember((value, buf) -> { + buf.writeBlockPos(value.pos()); + buf.writeUtf(value.channel(), ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + }, buf -> new C2SCreateChannelPacket(buf.readBlockPos(), buf.readUtf(ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH))); + + @Override + public Type type() { + return PACKET_ID; + } + + public static void receive(C2SCreateChannelPacket payload, ServerPlayer player) { + if (GNetworking.canUseTerminal(player, payload.pos())) { + ChannelManagerPersistence.get(player.level()).createChannel(payload.channel()); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/packets/C2SDeleteChannelPacket.java b/glass/src/main/java/dev/imb11/sync/packets/C2SDeleteChannelPacket.java new file mode 100644 index 0000000..9398432 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/packets/C2SDeleteChannelPacket.java @@ -0,0 +1,29 @@ +package dev.imb11.sync.packets; + +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record C2SDeleteChannelPacket(BlockPos pos, String channel) implements CustomPacketPayload { + public static final CustomPacketPayload.Type PACKET_ID = new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("glass", "delete_channel")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember((value, buf) -> { + buf.writeBlockPos(value.pos()); + buf.writeUtf(value.channel(), ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + }, buf -> new C2SDeleteChannelPacket(buf.readBlockPos(), buf.readUtf(ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH))); + + @Override + public Type type() { + return PACKET_ID; + } + + public static void receive(C2SDeleteChannelPacket payload, ServerPlayer player) { + if (GNetworking.canUseTerminal(player, payload.pos())) { + ChannelManagerPersistence.get(player.level()).deleteChannel(payload.channel()); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/packets/C2SProjectorChannelChangedPacket.java b/glass/src/main/java/dev/imb11/sync/packets/C2SProjectorChannelChangedPacket.java new file mode 100644 index 0000000..496df9d --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/packets/C2SProjectorChannelChangedPacket.java @@ -0,0 +1,36 @@ +package dev.imb11.sync.packets; + +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record C2SProjectorChannelChangedPacket(BlockPos pos, String channel) implements CustomPacketPayload { + public static final CustomPacketPayload.Type PACKET_ID = new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("glass", "projector_channel_changed")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember((value, buf) -> { + buf.writeBlockPos(value.pos()); + buf.writeUtf(value.channel(), ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + }, buf -> new C2SProjectorChannelChangedPacket(buf.readBlockPos(), buf.readUtf(ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH))); + + @Override + public Type type() { + return PACKET_ID; + } + + public static void receive(C2SProjectorChannelChangedPacket payload, ServerPlayer player) { + if (!GNetworking.canUseProjector(player, payload.pos())) { + return; + } + String channel = ChannelManagerPersistence.canonicalChannelName(payload.channel()); + var level = player.level(); + ChannelManagerPersistence manager = ChannelManagerPersistence.get(level); + if (channel != null && manager.containsName(channel) && level.getBlockEntity(payload.pos()) instanceof ProjectorBlockEntity projector) { + projector.setChannelFromServer(channel); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/packets/C2SRemoveLinkedChannelPacket.java b/glass/src/main/java/dev/imb11/sync/packets/C2SRemoveLinkedChannelPacket.java new file mode 100644 index 0000000..01ea62e --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/packets/C2SRemoveLinkedChannelPacket.java @@ -0,0 +1,32 @@ +package dev.imb11.sync.packets; + +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record C2SRemoveLinkedChannelPacket(BlockPos pos) implements CustomPacketPayload { + public static final CustomPacketPayload.Type PACKET_ID = new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("glass", "remove_linked_channel")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember((value, buf) -> buf.writeBlockPos(value.pos()), buf -> new C2SRemoveLinkedChannelPacket(buf.readBlockPos())); + + @Override + public Type type() { + return PACKET_ID; + } + + public static void receive(C2SRemoveLinkedChannelPacket payload, ServerPlayer player) { + if (!GNetworking.canUseTerminal(player, payload.pos())) { + return; + } + var level = player.level(); + if (level.getBlockEntity(payload.pos()) instanceof TerminalBlockEntity terminal) { + terminal.setChannelFromServer(""); + ChannelManagerPersistence.get(level).unlinkTerminal(level, payload.pos()); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/packets/C2STerminalChannelChangedPacket.java b/glass/src/main/java/dev/imb11/sync/packets/C2STerminalChannelChangedPacket.java new file mode 100644 index 0000000..1a52ca9 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/packets/C2STerminalChannelChangedPacket.java @@ -0,0 +1,43 @@ +package dev.imb11.sync.packets; + +import dev.imb11.blocks.TerminalBlock; +import dev.imb11.blocks.entity.TerminalBlockEntity; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.GNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record C2STerminalChannelChangedPacket(BlockPos pos, String channel) implements CustomPacketPayload { + public static final CustomPacketPayload.Type PACKET_ID = new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("glass", "terminal_channel_changed")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember((value, buf) -> { + buf.writeBlockPos(value.pos()); + buf.writeUtf(value.channel(), ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + }, buf -> new C2STerminalChannelChangedPacket(buf.readBlockPos(), buf.readUtf(ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH))); + + @Override + public Type type() { + return PACKET_ID; + } + + public static void receive(C2STerminalChannelChangedPacket payload, ServerPlayer player) { + if (!GNetworking.canUseTerminal(player, payload.pos())) { + return; + } + String channel = ChannelManagerPersistence.canonicalChannelName(payload.channel()); + if (channel == null) { + return; + } + var level = player.level(); + if (!(level.getBlockEntity(payload.pos()) instanceof TerminalBlockEntity terminal)) { + return; + } + ChannelManagerPersistence manager = ChannelManagerPersistence.get(level); + if (manager.claimTerminal(level, payload.pos(), level.getBlockState(payload.pos()).getValue(TerminalBlock.FACING), channel)) { + terminal.setChannelFromServer(channel); + } + } +} diff --git a/glass/src/main/java/dev/imb11/sync/packets/S2CChannelSnapshotPacket.java b/glass/src/main/java/dev/imb11/sync/packets/S2CChannelSnapshotPacket.java new file mode 100644 index 0000000..815fd5c --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/packets/S2CChannelSnapshotPacket.java @@ -0,0 +1,71 @@ +package dev.imb11.sync.packets; + +import dev.imb11.sync.Channel; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.ProjectionSource; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.Level; + +import java.util.ArrayList; +import java.util.List; + +public record S2CChannelSnapshotPacket(long registryRevision, List channels) implements CustomPacketPayload { + public static final CustomPacketPayload.Type PACKET_ID = new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("glass", "channel_snapshot")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CChannelSnapshotPacket::write, S2CChannelSnapshotPacket::read); + + public S2CChannelSnapshotPacket { + if (registryRevision < 0L) { + throw new IllegalArgumentException("registry revision must be non-negative"); + } + channels = List.copyOf(channels); + if (channels.size() > ChannelManagerPersistence.MAX_CHANNELS) { + throw new IllegalArgumentException("too many channels"); + } + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + buf.writeVarLong(registryRevision); + buf.writeVarInt(channels.size()); + for (Channel channel : channels) { + buf.writeUtf(channel.name(), ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + ProjectionSource source = channel.source(); + buf.writeBoolean(source != null); + if (source != null) { + buf.writeIdentifier(source.dimension().identifier()); + buf.writeBlockPos(source.pos()); + buf.writeEnum(source.facing()); + buf.writeVarLong(source.revision()); + } + } + } + + private static S2CChannelSnapshotPacket read(RegistryFriendlyByteBuf buf) { + long registryRevision = buf.readVarLong(); + int size = buf.readVarInt(); + if (size < 0 || size > ChannelManagerPersistence.MAX_CHANNELS) { + throw new IllegalArgumentException("invalid channel count"); + } + List channels = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + String name = buf.readUtf(ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + ProjectionSource source = null; + if (buf.readBoolean()) { + ResourceKey dimension = ResourceKey.create(Registries.DIMENSION, buf.readIdentifier()); + source = new ProjectionSource(name, dimension, buf.readBlockPos(), buf.readEnum(Direction.class), buf.readVarLong()); + } + channels.add(new Channel(name, source)); + } + return new S2CChannelSnapshotPacket(registryRevision, channels); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteChunkAckPacket.java b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteChunkAckPacket.java new file mode 100644 index 0000000..0ede8a1 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteChunkAckPacket.java @@ -0,0 +1,27 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; + +public record C2SRemoteChunkAckPacket(RemoteSubscriptionId subscription, long sequence, ChunkPos chunk) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_chunk_ack")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(C2SRemoteChunkAckPacket::write, C2SRemoteChunkAckPacket::read); + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + buf.writeChunkPos(chunk); + } + + private static C2SRemoteChunkAckPacket read(RegistryFriendlyByteBuf buf) { + return new C2SRemoteChunkAckPacket(RemoteSubscriptionId.read(buf), buf.readVarLong(), buf.readChunkPos()); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteSubscribePacket.java b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteSubscribePacket.java new file mode 100644 index 0000000..3330563 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteSubscribePacket.java @@ -0,0 +1,35 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; + +import java.util.Objects; + +public record C2SRemoteSubscribePacket(RemoteSubscriptionId subscription, ChunkPos cameraCenter, int requestedRadius) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_subscribe")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(C2SRemoteSubscribePacket::write, C2SRemoteSubscribePacket::read); + + public C2SRemoteSubscribePacket { + subscription = Objects.requireNonNull(subscription); + cameraCenter = Objects.requireNonNull(cameraCenter); + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeInt(cameraCenter.x()); + buf.writeInt(cameraCenter.z()); + buf.writeVarInt(requestedRadius); + } + + private static C2SRemoteSubscribePacket read(RegistryFriendlyByteBuf buf) { + return new C2SRemoteSubscribePacket(RemoteSubscriptionId.read(buf), new ChunkPos(buf.readInt(), buf.readInt()), buf.readVarInt()); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteUnsubscribePacket.java b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteUnsubscribePacket.java new file mode 100644 index 0000000..362a1c2 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteUnsubscribePacket.java @@ -0,0 +1,30 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import java.util.Objects; + +public record C2SRemoteUnsubscribePacket(RemoteSubscriptionId subscription) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_unsubscribe")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(C2SRemoteUnsubscribePacket::write, C2SRemoteUnsubscribePacket::read); + + public C2SRemoteUnsubscribePacket { + subscription = Objects.requireNonNull(subscription); + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + } + + private static C2SRemoteUnsubscribePacket read(RegistryFriendlyByteBuf buf) { + return new C2SRemoteUnsubscribePacket(RemoteSubscriptionId.read(buf)); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteUpdatePacket.java b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteUpdatePacket.java new file mode 100644 index 0000000..f36ed6d --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/C2SRemoteUpdatePacket.java @@ -0,0 +1,35 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; + +import java.util.Objects; + +public record C2SRemoteUpdatePacket(RemoteSubscriptionId subscription, ChunkPos cameraCenter, int requestedRadius) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_update")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(C2SRemoteUpdatePacket::write, C2SRemoteUpdatePacket::read); + + public C2SRemoteUpdatePacket { + subscription = Objects.requireNonNull(subscription); + cameraCenter = Objects.requireNonNull(cameraCenter); + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeInt(cameraCenter.x()); + buf.writeInt(cameraCenter.z()); + buf.writeVarInt(requestedRadius); + } + + private static C2SRemoteUpdatePacket read(RegistryFriendlyByteBuf buf) { + return new C2SRemoteUpdatePacket(RemoteSubscriptionId.read(buf), new ChunkPos(buf.readInt(), buf.readInt()), buf.readVarInt()); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/PreparedChunkCache.java b/glass/src/main/java/dev/imb11/sync/remote/PreparedChunkCache.java new file mode 100644 index 0000000..91daf15 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/PreparedChunkCache.java @@ -0,0 +1,116 @@ +package dev.imb11.sync.remote; + +import io.netty.buffer.Unpooled; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.LevelChunk; + +import java.lang.ref.WeakReference; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +final class PreparedChunkCache { + private static final int MAX_ENTRIES = 512; + private static final long MAX_BYTES = 32L * 1024L * 1024L; + private static final int RETENTION_TICKS = 600; + private final Map chunks = new LinkedHashMap<>(16, 0.75F, true); + private long bytes; + + PreparedChunk find(ServerLevel level, LevelChunk chunk) { + Key key = new Key(level.dimension(), chunk.getPos().pack()); + PreparedChunk prepared = chunks.get(key); + if (prepared != null && (prepared.chunk.get() != chunk + || (!chunk.getBlockEntities().isEmpty() && prepared.createdTick != level.getServer().getTickCount()) + || level.getServer().getTickCount() - prepared.lastUsedTick > RETENTION_TICKS)) { + invalidate(level.dimension(), chunk.getPos()); + return null; + } + if (prepared != null) { + prepared.lastUsedTick = level.getServer().getTickCount(); + } + return prepared; + } + + PreparedChunk prepare(ServerLevel level, LevelChunk chunk) { + PreparedChunk existing = find(level, chunk); + if (existing != null) { + return existing; + } + ClientboundLevelChunkWithLightPacket packet = new ClientboundLevelChunkWithLightPacket(chunk, level.getLightEngine(), null, null); + RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), level.registryAccess()); + int size; + try { + ClientboundLevelChunkWithLightPacket.STREAM_CODEC.encode(buffer, packet); + size = buffer.readableBytes(); + if (size > S2CRemoteChunkPacket.MAX_BYTES) { + throw new IllegalArgumentException("Projection chunk exceeds packet limit"); + } + } finally { + buffer.release(); + } + PreparedChunk prepared = new PreparedChunk(chunk, packet, size, level.getServer().getTickCount()); + chunks.put(new Key(level.dimension(), chunk.getPos().pack()), prepared); + bytes += prepared.size; + trim(level.getServer().getTickCount()); + return prepared; + } + + void invalidate(ResourceKey dimension, ChunkPos pos) { + PreparedChunk removed = chunks.remove(new Key(dimension, pos.pack())); + if (removed != null) { + bytes -= removed.size; + } + } + + void unload(ResourceKey dimension) { + Iterator> iterator = chunks.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getKey().dimension().equals(dimension)) { + bytes -= entry.getValue().size; + iterator.remove(); + } + } + } + + void trim(int now) { + Iterator iterator = chunks.values().iterator(); + while (iterator.hasNext()) { + PreparedChunk chunk = iterator.next(); + if (now - chunk.lastUsedTick > RETENTION_TICKS || chunk.chunk.get() == null + || chunks.size() > MAX_ENTRIES || bytes > MAX_BYTES) { + bytes -= chunk.size; + iterator.remove(); + } + } + } + + private record Key(ResourceKey dimension, long chunk) { + } + + static final class PreparedChunk { + private final WeakReference chunk; + private final ClientboundLevelChunkWithLightPacket data; + private final int size; + private final int createdTick; + private int lastUsedTick; + + private PreparedChunk(LevelChunk chunk, ClientboundLevelChunkWithLightPacket data, int size, int now) { + this.chunk = new WeakReference<>(chunk); + this.data = data; + this.size = size; + this.createdTick = now; + this.lastUsedTick = now; + } + + S2CRemoteChunkPacket packet(RemoteSubscriptionId subscription, long sequence) { + return new S2CRemoteChunkPacket(subscription, sequence, data, size); + } + + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/RemoteSceneServerManager.java b/glass/src/main/java/dev/imb11/sync/remote/RemoteSceneServerManager.java new file mode 100644 index 0000000..ad3ac5b --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/RemoteSceneServerManager.java @@ -0,0 +1,1200 @@ +package dev.imb11.sync.remote; + +import dev.imb11.blocks.GBlocks; +import org.jetbrains.annotations.Nullable; +import dev.imb11.debug.RemoteStreamTrace; +import dev.imb11.projection.ProjectionChunkRegion; +import dev.imb11.blocks.entity.ProjectorBlockEntity; +import dev.imb11.mixins.ChunkMapAccessor; +import dev.imb11.mixins.TrackedEntityAccessor; +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.platform.PlatformNetworking; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket; +import net.minecraft.network.protocol.game.ClientboundBundlePacket; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.network.protocol.game.ClientboundMoveEntityPacket; +import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket; +import net.minecraft.network.protocol.game.ClientboundLightUpdatePacket; +import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket; +import it.unimi.dsi.fastutil.shorts.ShortOpenHashSet; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.BitSet; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.UUID; + +public final class RemoteSceneServerManager { + public static final int MAX_RADIUS = 6; + public static final int MIN_RADIUS = 1; + public static final int SOURCE_CAMERA_BUDGET = 32; + public static final int MAX_SUBSCRIPTIONS_PER_PLAYER = 4; + public static final int MAX_CHUNKS_PER_PLAYER = 512; + public static final int MAX_CHUNKS_GLOBAL = 4096; + public static final int SUBSCRIPTION_TIMEOUT_TICKS = 100; + private static final int TICKET_TIMEOUT_TICKS = 120; + private static final int TICKET_REFRESH_INTERVAL = 20; + private static final int STATE_INTERVAL = 20; + private static final int MAX_CHUNKS_PER_PLAYER_TICK = 4; + private static final int MAX_CHUNKS_GLOBAL_TICK = 8; + private static final int MAX_IN_FLIGHT_PER_PLAYER = 16; + private static final int CHUNK_ACK_TIMEOUT_TICKS = 200; + private static final int SEND_READY_TICKET_LEVEL = 32; + private static final long MAX_CHUNK_BYTES_PER_PLAYER_TICK = 1024L * 1024L; + private static final long MAX_CHUNK_BYTES_GLOBAL_TICK = 4L * 1024L * 1024L; + private static final int MAX_INCREMENTAL_PACKETS_GLOBAL_TICK = 32; + private static final int MAX_LOAD_PROBES = 8; + private static final int MAX_PREPARED_CHUNKS_PER_TICK = 8; + private static final long PREPARATION_BUDGET_NANOS = 2_000_000L; + private static final int MAX_CHUNK_COORDINATE = 1_875_000; + private static final int MAX_DIRTY_SECTIONS = 256; + private static final int MAX_REMOTE_ENTITIES = 128; + private static final int MAX_ENTITY_SPAWNS_PER_TICK = 8; + private static final Map STATES = new IdentityHashMap<>(); + public static final TicketType REMOTE_TICKET = new TicketType(TICKET_TIMEOUT_TICKS, TicketType.FLAG_LOADING | TicketType.FLAG_SIMULATION); + + public static void registerTicketType() { + net.minecraft.core.Registry.register(net.minecraft.core.registries.BuiltInRegistries.TICKET_TYPE, + net.minecraft.resources.Identifier.fromNamespaceAndPath("glass", "remote_scene"), REMOTE_TICKET); + } + + public static void subscribe(ServerPlayer player, C2SRemoteSubscribePacket packet) { + MinecraftServer server = player.level().getServer(); + if (server == null) { + return; + } + if (!server.isSameThread()) { + server.execute(() -> subscribe(player, packet)); + return; + } + ServerState state = STATES.computeIfAbsent(server, ignored -> new ServerState()); + PlayerState playerState = state.players.computeIfAbsent(player.getUUID(), ignored -> new PlayerState()); + RemoteSubscriptionId id = packet.subscription(); + Subscription existing = playerState.subscriptions.get(id); + if (existing != null) { + update(player, new C2SRemoteUpdatePacket(id, packet.cameraCenter(), packet.requestedRadius())); + return; + } + if (id.epoch() <= playerState.latestEpoch) { + RemoteStreamTrace.rejectedStale(player.getUUID(), id, playerState.latestEpoch); + send(player, new S2CRemoteUnavailablePacket(id, S2CRemoteUnavailablePacket.Reason.INVALID)); + return; + } + List replacements = playerState.subscriptions.values().stream() + .filter(subscription -> subscription.id.sameProjector(id)) + .toList(); + boolean overBudget = playerState.subscriptions.size() - replacements.size() >= MAX_SUBSCRIPTIONS_PER_PLAYER; + Denial denial = denial(server, player, id, packet.cameraCenter()); + if (overBudget + || packet.requestedRadius() < 1 + || packet.requestedRadius() > MAX_RADIUS + || denial != null) { + RemoteStreamTrace.rejected("subscribe", player.getUUID(), id, packet.requestedRadius(), denial, + "subscriptions=" + playerState.subscriptions.size() + " replacements=" + replacements.size()); + send(player, new S2CRemoteUnavailablePacket(id, overBudget + ? S2CRemoteUnavailablePacket.Reason.BUDGET + : S2CRemoteUnavailablePacket.Reason.INVALID)); + return; + } + for (Subscription replacement : replacements) { + closeSubscription(server, state, player, replacement, S2CRemoteUnavailablePacket.Reason.ENDED, true); + playerState.subscriptions.remove(replacement.id); + } + playerState.latestEpoch = id.epoch(); + Subscription subscription = new Subscription(player.getUUID(), id, packet.cameraCenter(), packet.requestedRadius(), server.getTickCount()); + playerState.subscriptions.put(id, subscription); + RemoteStreamTrace.accepted(player.getUUID(), id, packet.cameraCenter(), packet.requestedRadius()); + rebalance(server, state); + } + + private static boolean authorizedViewer(ServerPlayer player, ServerLevel level, RemoteSubscriptionId id) { + if (id.preview()) { + return player.containerMenu instanceof dev.imb11.client.gui.ChannelMenu menu + && menu.isFor(id.projectorPos()) && menu.stillValid(player); + } + return level.getBlockState(id.projectorPos()).is(GBlocks.PROJECTOR) + && level.getBlockEntity(id.projectorPos()) instanceof ProjectorBlockEntity projector + && projector.getChannel().equals(id.source().channel()); + } + + public static void update(ServerPlayer player, C2SRemoteUpdatePacket packet) { + MinecraftServer server = player.level().getServer(); + if (server == null) { + return; + } + if (!server.isSameThread()) { + server.execute(() -> update(player, packet)); + return; + } + ServerState state = STATES.get(server); + PlayerState playerState = state == null ? null : state.players.get(player.getUUID()); + Subscription subscription = playerState == null ? null : playerState.subscriptions.get(packet.subscription()); + RemoteSubscriptionId id = packet.subscription(); + Denial denial = denial(server, player, id, packet.cameraCenter()); + if (subscription == null + || packet.requestedRadius() < 1 + || packet.requestedRadius() > MAX_RADIUS + || denial != null) { + RemoteStreamTrace.rejected("update", player.getUUID(), id, packet.requestedRadius(), denial, + "subscriptionExists=" + (subscription != null)); + send(player, new S2CRemoteUnavailablePacket(packet.subscription(), S2CRemoteUnavailablePacket.Reason.INVALID)); + return; + } + boolean changed = subscription.requestedRadius != packet.requestedRadius() + || !subscription.requestedCenter.equals(packet.cameraCenter()); + subscription.lastSeenTick = server.getTickCount(); + subscription.requestedRadius = packet.requestedRadius(); + subscription.requestedCenter = packet.cameraCenter(); + if (changed) { + rebalance(server, state); + } + } + + public static void acknowledge(ServerPlayer player, C2SRemoteChunkAckPacket packet) { + ServerState state = STATES.get(player.level().getServer()); + PlayerState subscriptions = state == null ? null : state.players.get(player.getUUID()); + Subscription subscription = subscriptions == null ? null : subscriptions.subscriptions.get(packet.subscription()); + InFlight flight = subscription == null ? null : subscription.inFlight.get(packet.chunk()); + if (flight != null && flight.sequence() == packet.sequence()) { + subscription.inFlight.remove(packet.chunk()); + subscription.acknowledgedChunks.add(packet.chunk()); + } + } + + public static void unsubscribe(ServerPlayer player, C2SRemoteUnsubscribePacket packet) { + MinecraftServer server = player.level().getServer(); + if (server == null) { + return; + } + if (!server.isSameThread()) { + server.execute(() -> unsubscribe(player, packet)); + return; + } + ServerState state = STATES.get(server); + PlayerState playerState = state == null ? null : state.players.get(player.getUUID()); + if (playerState == null) { + return; + } + Subscription subscription = playerState.subscriptions.remove(packet.subscription()); + if (subscription != null) { + closeSubscription(server, state, player, subscription, S2CRemoteUnavailablePacket.Reason.ENDED, false); + rebalance(server, state); + } + } + + public static void onBlockChanged(ServerLevel level, BlockPos pos) { + MinecraftServer server = level.getServer(); + if (!server.isSameThread()) { + BlockPos immutable = pos.immutable(); + server.execute(() -> onBlockChanged(level, immutable)); + return; + } + ServerState state = STATES.get(server); + if (state == null) { + return; + } + ChunkPos chunk = ChunkPos.containing(pos); + state.preparedChunks.invalidate(level.dimension(), chunk); + SectionPos section = SectionPos.of(pos); + short packed = SectionPos.sectionRelativePos(pos); + for (PlayerState playerState : state.players.values()) { + for (Subscription subscription : playerState.subscriptions.values()) { + if (!subscription.id.source().dimension().equals(level.dimension()) || !subscription.sentChunks.contains(chunk) + || vanillaTracks(server.getPlayerList().getPlayer(subscription.player), level, chunk)) { + continue; + } + if (level.getBlockEntity(pos) != null) { + subscription.blockEntities.add(pos.immutable()); + } + LinkedHashSet positions = subscription.blockChanges.get(section); + if (positions == null) { + if (subscription.blockChanges.size() >= MAX_DIRTY_SECTIONS) { + subscription.resyncChunks.add(chunk); + continue; + } + positions = new LinkedHashSet<>(); + subscription.blockChanges.put(section, positions); + } + if (positions.size() >= S2CRemoteBlockUpdatesPacket.MAX_UPDATES && !positions.contains(packed)) { + subscription.blockChanges.remove(section); + subscription.resyncChunks.add(chunk); + } else { + positions.add(packed); + } + } + } + } + + public static void onEntityPacket(Entity entity, Packet packet) { + if (!(entity.level() instanceof ServerLevel level)) { + return; + } + if (packet instanceof ClientboundBundlePacket bundle) { + for (Packet child : bundle.subPackets()) { + onEntityPacket(entity, child); + } + return; + } + if (packet instanceof ClientboundMoveEntityPacket) { + packet = net.minecraft.network.protocol.game.ClientboundEntityPositionSyncPacket.of(entity); + } + if (!S2CRemoteEntitiesPacket.supports(packet)) { + return; + } + ServerState state = STATES.get(level.getServer()); + if (state == null) { + return; + } + Object tracker = ((ChunkMapAccessor) level.getChunkSource().chunkMap).glass$getEntityMap().get(entity.getId()); + for (Map.Entry entry : state.players.entrySet()) { + ServerPlayer viewer = level.getServer().getPlayerList().getPlayer(entry.getKey()); + if (viewer == entity || (tracker instanceof TrackedEntityAccessor tracked && viewer != null + && tracked.glass$getSeenBy().contains(viewer.connection))) { + continue; + } + for (Subscription subscription : entry.getValue().subscriptions.values()) { + if (subscription.id.source().dimension().equals(level.dimension()) + && subscription.entities.contains(entity.getId())) { + queueEntityPacket(subscription, entity.getId(), packet); + } + } + } + } + + public static void onEntityRemoved(Entity entity) { + if (!(entity.level() instanceof ServerLevel level)) { + return; + } + ServerState state = STATES.get(level.getServer()); + if (state == null) { + return; + } + for (PlayerState player : state.players.values()) { + for (Subscription subscription : player.subscriptions.values()) { + if (subscription.id.source().dimension().equals(level.dimension()) + && subscription.entities.remove(entity.getId())) { + queueEntityPacket(subscription, entity.getId(), new ClientboundRemoveEntitiesPacket(entity.getId())); + } + } + } + } + + private static void queueEntityPacket(Subscription subscription, int entityId, Packet packet) { + if (S2CRemoteEntitiesPacket.supports(packet)) { + if (subscription.entityPackets.size() >= 1024) { + subscription.entityOverflow = true; + return; + } + subscription.entityPackets.addLast(new S2CRemoteEntitiesPacket.EntityMessage(entityId, packet)); + } + } + + private static void streamEntities(MinecraftServer server, ServerState state) { + for (PlayerState playerState : state.players.values()) { + for (Subscription subscription : List.copyOf(playerState.subscriptions.values())) { + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + ServerPlayer player = server.getPlayerList().getPlayer(subscription.player); + if (level == null || player == null || subscription.grantedRadius < 1) { + continue; + } + if (subscription.entityOverflow) { + invalidateSubscription(server, state, subscription, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + continue; + } + Iterator existing = subscription.entities.iterator(); + while (existing.hasNext()) { + int id = existing.next(); + Entity entity = level.getEntity(id); + if (entity == null || entity.isRemoved() || !subscription.sentChunks.contains(entity.chunkPosition()) + || !entity.broadcastToPlayer(player)) { + existing.remove(); + queueEntityPacket(subscription, id, new ClientboundRemoveEntitiesPacket(id)); + } + } + if (subscription.entities.size() < MAX_REMOTE_ENTITIES) { + int radius = subscription.grantedRadius; + ChunkPos center = subscription.grantedCenter; + AABB bounds = new AABB((center.x() - radius) * 16.0, level.getMinY(), (center.z() - radius) * 16.0, + (center.x() + radius + 1) * 16.0, level.getMaxY() + 1.0, (center.z() + radius + 1) * 16.0); + int spawned = 0; + var tracked = ((ChunkMapAccessor) level.getChunkSource().chunkMap).glass$getEntityMap(); + for (Entity entity : level.getEntities((Entity) null, bounds, entity -> !entity.isRemoved())) { + if (spawned >= MAX_ENTITY_SPAWNS_PER_TICK || subscription.entities.size() >= MAX_REMOTE_ENTITIES) { + break; + } + Object tracker = tracked.get(entity.getId()); + if (subscription.entities.contains(entity.getId()) || tracker == null + || !subscription.sentChunks.contains(entity.chunkPosition()) || !entity.broadcastToPlayer(player)) { + continue; + } + subscription.entities.add(entity.getId()); + PlatformNetworking.sendPairingData(((TrackedEntityAccessor) tracker).glass$getServerEntity(), player, + packet -> queueEntityPacket(subscription, entity.getId(), packet)); + spawned++; + } + } + if (!subscription.entityPackets.isEmpty()) { + List messages = new ArrayList<>(); + while (messages.size() < S2CRemoteEntitiesPacket.MAX_MESSAGES && !subscription.entityPackets.isEmpty()) { + messages.add(subscription.entityPackets.removeFirst()); + } + send(player, new S2CRemoteEntitiesPacket(subscription.id, subscription.nextSequence(), messages)); + } + } + } + } + + public static void onLightChanged(ServerLevel level, LightLayer layer, SectionPos section) { + MinecraftServer server = level.getServer(); + if (!server.isSameThread()) { + SectionPos immutable = SectionPos.of(section.x(), section.y(), section.z()); + server.execute(() -> onLightChanged(level, layer, immutable)); + return; + } + ServerState state = STATES.get(server); + if (state == null) { + return; + } + ChunkPos chunk = section.chunk(); + state.preparedChunks.invalidate(level.dimension(), chunk); + int index = section.y() - level.getLightEngine().getMinLightSection(); + if (index < 0 || index >= level.getLightEngine().getLightSectionCount()) { + return; + } + for (PlayerState playerState : state.players.values()) { + for (Subscription subscription : playerState.subscriptions.values()) { + if (!subscription.id.source().dimension().equals(level.dimension()) || !subscription.sentChunks.contains(chunk) + || vanillaTracks(server.getPlayerList().getPlayer(subscription.player), level, chunk)) { + continue; + } + LightChanges changes = subscription.lightChanges.computeIfAbsent(chunk, ignored -> new LightChanges()); + (layer == LightLayer.SKY ? changes.sky : changes.block).set(index); + } + } + } + + public static void onBiomesChanged(ServerLevel level, Collection chunks) { + List positions = chunks.stream().map(ChunkAccess::getPos).toList(); + MinecraftServer server = level.getServer(); + if (server.isSameThread()) { + onBiomePositionsChanged(level, positions); + } else { + server.execute(() -> onBiomePositionsChanged(level, positions)); + } + } + + private static void onBiomePositionsChanged(ServerLevel level, Collection chunks) { + ServerState state = STATES.get(level.getServer()); + if (state == null) { + return; + } + for (ChunkPos chunk : chunks) { + state.preparedChunks.invalidate(level.dimension(), chunk); + } + for (PlayerState playerState : state.players.values()) { + for (Subscription subscription : playerState.subscriptions.values()) { + if (!subscription.id.source().dimension().equals(level.dimension())) { + continue; + } + for (ChunkPos chunk : chunks) { + if (subscription.sentChunks.contains(chunk) + && !vanillaTracks(level.getServer().getPlayerList().getPlayer(subscription.player), level, chunk)) { + subscription.resyncChunks.add(chunk); + } + } + } + } + } + + public static void tick(MinecraftServer server) { + ServerState state = STATES.get(server); + if (state == null) { + return; + } + int now = server.getTickCount(); + List removals = new ArrayList<>(); + for (Map.Entry playerEntry : state.players.entrySet()) { + ServerPlayer player = server.getPlayerList().getPlayer(playerEntry.getKey()); + for (Subscription subscription : playerEntry.getValue().subscriptions.values()) { + for (Map.Entry flight : List.copyOf(subscription.inFlight.entrySet())) { + if (now - flight.getValue().sentTick() >= CHUNK_ACK_TIMEOUT_TICKS) { + subscription.inFlight.remove(flight.getKey()); + subscription.acknowledgedChunks.remove(flight.getKey()); + subscription.resyncChunks.add(flight.getKey()); + } + } + if (player == null || now - subscription.lastSeenTick > SUBSCRIPTION_TIMEOUT_TICKS) { + removals.add(new Removal(playerEntry.getKey(), subscription, S2CRemoteUnavailablePacket.Reason.TIMEOUT)); + } else if (denial(server, player, subscription.id, subscription.requestedCenter) != null) { + removals.add(new Removal(playerEntry.getKey(), subscription, S2CRemoteUnavailablePacket.Reason.INVALID)); + } + } + } + remove(server, state, removals); + state.players.entrySet().removeIf(entry -> entry.getValue().subscriptions.isEmpty() + && server.getPlayerList().getPlayer(entry.getKey()) == null); + if (!removals.isEmpty() || state.rebalanceNeeded) { + state.rebalanceNeeded = false; + rebalance(server, state); + } + if (now % TICKET_REFRESH_INTERVAL == 0) { + refreshTickets(server, state); + } + flushIncremental(server, state); + preparePendingChunks(server, state); + streamChunks(server, state); + streamEntities(server, state); + if (now % STATE_INTERVAL == 0) { + state.preparedChunks.trim(now); + sendWorldStates(server, state); + logStreamProgress(server, state); + } + } + + @Nullable + private static Denial denial(MinecraftServer server, @Nullable ServerPlayer player, RemoteSubscriptionId id, ChunkPos center) { + ServerLevel projectorLevel = server.getLevel(id.projectorDimension()); + if (player == null || !player.level().dimension().equals(id.projectorDimension())) { + return Denial.PLAYER_DIMENSION; + } + if (!safeCenter(center)) { + return Denial.UNSAFE_CENTER; + } + if (projectorLevel == null || !projectorLevel.hasChunkAt(id.projectorPos())) { + return Denial.PROJECTOR_UNLOADED; + } + if (server.getLevel(id.source().dimension()) == null) { + return Denial.SOURCE_LEVEL_MISSING; + } + if (!authorizedViewer(player, projectorLevel, id)) { + return Denial.VIEWER_NOT_AUTHORIZED; + } + if (ChannelManagerPersistence.get(server).resolve(id.source().channel()).filter(id.source()::equals).isEmpty()) { + return Denial.STALE_SOURCE; + } + if (!PlatformNetworking.tracking(projectorLevel, id.projectorPos()).contains(player)) { + return Denial.NOT_TRACKING; + } + if (chunkDistance(ChunkPos.containing(id.source().pos()), center) > SOURCE_CAMERA_BUDGET) { + return Denial.SOURCE_TOO_FAR; + } + return null; + } + + private static void logStreamProgress(MinecraftServer server, ServerState state) { + int now = server.getTickCount(); + for (PlayerState playerState : state.players.values()) { + for (Subscription subscription : playerState.subscriptions.values()) { + if (subscription.grantedRadius >= 1) { + subscription.trace.progress(subscription.summary(now), server.getLevel(subscription.id.source().dimension()), now); + } + } + } + } + + private static void rebalance(MinecraftServer server, ServerState state) { + List ordered = state.players.values().stream() + .flatMap(playerState -> playerState.subscriptions.values().stream()) + .sorted(SUBSCRIPTION_COMPARATOR) + .toList(); + Map> playerChunks = new HashMap<>(); + Set globalChunks = new HashSet<>(); + Map grants = new IdentityHashMap<>(); + for (Subscription subscription : ordered) { + Set playerUsed = playerChunks.computeIfAbsent(subscription.player, ignored -> new HashSet<>()); + grants.put(subscription, reserve(subscription, MIN_RADIUS, playerUsed, globalChunks) ? MIN_RADIUS : 0); + } + for (Subscription subscription : ordered) { + if (grants.get(subscription) == 0) { + continue; + } + Set playerUsed = playerChunks.get(subscription.player); + for (int radius = Math.min(MAX_RADIUS, subscription.requestedRadius); radius > MIN_RADIUS; radius--) { + if (reserve(subscription, radius, playerUsed, globalChunks)) { + grants.put(subscription, radius); + break; + } + } + } + for (Subscription subscription : ordered) { + int granted = grants.get(subscription); + if (subscription.grantedRadius != granted || !subscription.grantedCenter.equals(subscription.requestedCenter)) { + configureInterest(server, state, subscription, subscription.requestedCenter, granted); + } + } + } + + private static boolean reserve(Subscription subscription, int radius, Set playerUsed, Set globalUsed) { + Set needed = regionKeys(subscription, radius); + long additionalPlayer = needed.stream().filter(key -> !playerUsed.contains(key)).count(); + long additionalGlobal = needed.stream().filter(key -> !globalUsed.contains(key)).count(); + if (playerUsed.size() + additionalPlayer > MAX_CHUNKS_PER_PLAYER + || globalUsed.size() + additionalGlobal > MAX_CHUNKS_GLOBAL) { + return false; + } + playerUsed.addAll(needed); + globalUsed.addAll(needed); + return true; + } + + private static Set regionKeys(Subscription subscription, int radius) { + Set keys = new HashSet<>(); + for (ChunkPos chunk : new ProjectionChunkRegion(subscription.requestedCenter, radius).withNeighbors().chunks()) { + keys.add(new TicketKey(subscription.id.source().dimension(), chunk.pack())); + } + return keys; + } + + private static boolean vanillaTracks(ServerPlayer player, ServerLevel level, ChunkPos chunk) { + return player != null && player.level() == level + && ((ChunkMapAccessor) level.getChunkSource().chunkMap).glass$isChunkTracked(player, chunk.x(), chunk.z()); + } + + private static boolean reuseChunk(MinecraftServer server, ServerState state, Subscription subscription, ServerLevel level, ChunkPos chunk) { + ServerPlayer player = server.getPlayerList().getPlayer(subscription.player); + if (vanillaTracks(player, level, chunk)) { + if (subscription.sentChunks.add(chunk)) { + subscription.trace.reusedPlayerChunk(); + } + subscription.acknowledgedChunks.add(chunk); + return true; + } + PlayerState owner = state.players.get(subscription.player); + if (owner != null) { + for (Subscription other : owner.subscriptions.values()) { + if (other != subscription && other.id.source().dimension().equals(level.dimension()) + && other.acknowledgedChunks.contains(chunk)) { + if (subscription.sentChunks.add(chunk)) { + subscription.trace.reusedProjectionChunk(); + } + subscription.acknowledgedChunks.add(chunk); + return true; + } + } + } + return false; + } + + private static boolean chunkInFlight(ServerState state, Subscription subscription, ChunkPos chunk) { + PlayerState player = state.players.get(subscription.player); + return player.subscriptions.values().stream().anyMatch(other -> + other.id.source().dimension().equals(subscription.id.source().dimension()) && other.inFlight.containsKey(chunk)); + } + + private static void retainTicket(ServerState state, ServerLevel level, TicketKey key) { + if (state.ticketReferences.merge(key, 1, Integer::sum) == 1) { + level.getChunkSource().addTicketWithRadius(REMOTE_TICKET, + ChunkPos.unpack(key.chunk()), 33 - SEND_READY_TICKET_LEVEL); + } + } + + private static void releaseTicket(ServerState state, ServerLevel level, TicketKey key) { + int remaining = state.ticketReferences.getOrDefault(key, 1) - 1; + if (remaining <= 0) { + state.ticketReferences.remove(key); + level.getChunkSource().removeTicketWithRadius(REMOTE_TICKET, + ChunkPos.unpack(key.chunk()), 33 - SEND_READY_TICKET_LEVEL); + } else { + state.ticketReferences.put(key, remaining); + } + } + + private static void configureInterest(MinecraftServer server, ServerState state, Subscription subscription, ChunkPos center, int radius) { + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + if (level == null) { + return; + } + var dimensionType = level.dimensionTypeRegistration().unwrapKey(); + boolean unsupported = radius > 0 && dimensionType.isEmpty(); + Set next = radius == 0 || unsupported ? Set.of() : new ProjectionChunkRegion(center, radius).withNeighbors().chunks(); + Set removed = new HashSet<>(subscription.interest); + removed.removeAll(next); + Set added = new HashSet<>(next); + added.removeAll(subscription.interest); + for (ChunkPos chunk : added) { + TicketKey key = new TicketKey(level.dimension(), chunk.pack()); + retainTicket(state, level, key); + subscription.tickets.put(chunk, key); + } + for (ChunkPos chunk : removed) { + TicketKey key = subscription.tickets.remove(chunk); + if (key != null) { + releaseTicket(state, level, key); + } + subscription.pendingChunks.remove(chunk); + subscription.acknowledgedChunks.remove(chunk); + subscription.inFlight.remove(chunk); + subscription.refreshChunks.remove(chunk); + subscription.resyncChunks.remove(chunk); + subscription.lightChanges.remove(chunk); + subscription.blockEntities.removeIf(pos -> ChunkPos.containing(pos).equals(chunk)); + subscription.blockChanges.keySet().removeIf(section -> section.chunk().equals(chunk)); + if (subscription.sentChunks.remove(chunk)) { + sendToSubscription(server, subscription, new S2CRemoteUnloadPacket(subscription.id, subscription.nextSequence(), chunk.x(), chunk.z())); + } + } + subscription.interest.clear(); + subscription.interest.addAll(next); + subscription.grantedCenter = center; + subscription.grantedRadius = unsupported ? 0 : radius; + subscription.pendingChunks = new PriorityQueue<>(chunkComparator(center)); + for (ChunkPos chunk : next) { + if (subscription.refreshChunks.contains(chunk) + || (!subscription.sentChunks.contains(chunk) && !reuseChunk(server, state, subscription, level, chunk))) { + subscription.pendingChunks.add(chunk); + } + } + ServerPlayer player = server.getPlayerList().getPlayer(subscription.player); + int now = server.getTickCount(); + subscription.trace.granted(subscription.summary(now), subscription.requestedRadius, added.size(), removed.size(), unsupported, now); + if (unsupported) { + invalidateSubscription(server, state, subscription, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + return; + } + if (radius == 0) { + if (!subscription.budgetUnavailable) { + send(player, new S2CRemoteUnavailablePacket(subscription.id, S2CRemoteUnavailablePacket.Reason.BUDGET)); + subscription.budgetUnavailable = true; + } + return; + } + subscription.budgetUnavailable = false; + send(player, new S2CRemoteSubscriptionPacket( + subscription.id, + center, + radius, + dimensionType.get(), + level.getDifficulty(), + server.isHardcore(), + level.isDebug(), + level.isFlat(), + BiomeManager.obfuscateSeed(level.getSeed()), + level.getSeaLevel(), + worldState(level) + )); + } + + private static void refreshTickets(MinecraftServer server, ServerState state) { + for (TicketKey key : state.ticketReferences.keySet()) { + ServerLevel level = server.getLevel(key.dimension()); + if (level != null) { + level.getChunkSource().addTicketWithRadius(REMOTE_TICKET, + ChunkPos.unpack(key.chunk()), 33 - SEND_READY_TICKET_LEVEL); + } + } + } + + private static void flushIncremental(MinecraftServer server, ServerState state) { + int budget = MAX_INCREMENTAL_PACKETS_GLOBAL_TICK; + List ordered = state.players.values().stream() + .flatMap(playerState -> playerState.subscriptions.values().stream()) + .sorted(SUBSCRIPTION_COMPARATOR) + .toList(); + for (Subscription subscription : ordered) { + if (budget <= 0) { + break; + } + for (ChunkPos chunk : List.copyOf(subscription.resyncChunks)) { + subscription.resyncChunks.remove(chunk); + subscription.lightChanges.remove(chunk); + subscription.blockChanges.keySet().removeIf(section -> section.chunk().equals(chunk)); + if (subscription.interest.contains(chunk)) { + subscription.refreshChunks.add(chunk); + subscription.acknowledgedChunks.remove(chunk); + subscription.pendingChunks.remove(chunk); + subscription.pendingChunks.add(chunk); + } + } + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + if (level == null) { + continue; + } + if (!subscription.blockEntities.isEmpty() && budget > 0) { + List updates = new ArrayList<>(); + Iterator positions = subscription.blockEntities.iterator(); + while (positions.hasNext() && updates.size() < S2CRemoteBlockEntitiesPacket.MAX_UPDATES) { + BlockPos pos = positions.next(); + if (subscription.blockChanges.containsKey(SectionPos.of(pos)) + || subscription.pendingChunks.contains(ChunkPos.containing(pos))) { + continue; + } + positions.remove(); + if (subscription.sentChunks.contains(ChunkPos.containing(pos)) + && !vanillaTracks(server.getPlayerList().getPlayer(subscription.player), level, ChunkPos.containing(pos))) { + var blockEntity = level.getBlockEntity(pos); + if (blockEntity != null) { + updates.add(ClientboundBlockEntityDataPacket.create(blockEntity)); + } + } + } + if (!updates.isEmpty()) { + sendToSubscription(server, subscription, new S2CRemoteBlockEntitiesPacket(subscription.id, subscription.nextSequence(), updates)); + budget--; + } + } + Iterator>> blockIterator = subscription.blockChanges.entrySet().iterator(); + while (budget > 0 && blockIterator.hasNext()) { + Map.Entry> entry = blockIterator.next(); + SectionPos section = entry.getKey(); + if (!subscription.sentChunks.contains(section.chunk()) + || vanillaTracks(server.getPlayerList().getPlayer(subscription.player), level, section.chunk())) { + blockIterator.remove(); + continue; + } + LevelChunk chunk = level.getChunkSource().getChunkNow(section.x(), section.z()); + if (chunk == null || section.y() < level.getMinSectionY() || section.y() > level.getMaxSectionY()) { + blockIterator.remove(); + continue; + } + ClientboundSectionBlocksUpdatePacket updates = new ClientboundSectionBlocksUpdatePacket(section, + new ShortOpenHashSet(entry.getValue()), chunk.getSection(level.getSectionIndexFromSectionY(section.y()))); + sendToSubscription(server, subscription, new S2CRemoteBlockUpdatesPacket(subscription.id, subscription.nextSequence(), section.x(), section.y(), section.z(), updates)); + blockIterator.remove(); + budget--; + } + Iterator> lightIterator = subscription.lightChanges.entrySet().iterator(); + boolean unsupported = false; + while (budget > 0 && lightIterator.hasNext()) { + Map.Entry entry = lightIterator.next(); + if (!subscription.sentChunks.contains(entry.getKey()) + || vanillaTracks(server.getPlayerList().getPlayer(subscription.player), level, entry.getKey())) { + lightIterator.remove(); + continue; + } + try { + ClientboundLightUpdatePacket light = new ClientboundLightUpdatePacket(entry.getKey(), level.getLightEngine(), entry.getValue().sky, entry.getValue().block); + sendToSubscription(server, subscription, new S2CRemoteLightPacket(subscription.id, subscription.nextSequence(), light)); + } catch (IllegalArgumentException exception) { + unsupported = true; + break; + } + lightIterator.remove(); + budget--; + } + if (unsupported) { + invalidateSubscription(server, state, subscription, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + } + } + } + + private static void streamChunks(MinecraftServer server, ServerState state) { + int globalBudget = MAX_CHUNKS_GLOBAL_TICK; + List> players = state.players.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .toList(); + if (players.isEmpty()) { + return; + } + int playerStart = Math.floorMod(state.playerLoadCursor, players.size()); + int visitedPlayers = 0; + long globalBytes = 0L; + for (; visitedPlayers < players.size(); visitedPlayers++) { + if (globalBudget <= 0 || globalBytes >= MAX_CHUNK_BYTES_GLOBAL_TICK) { + break; + } + Map.Entry playerEntry = players.get((playerStart + visitedPlayers) % players.size()); + ServerPlayer player = server.getPlayerList().getPlayer(playerEntry.getKey()); + if (player == null) { + continue; + } + int inFlight = playerEntry.getValue().subscriptions.values().stream().mapToInt(subscription -> subscription.inFlight.size()).sum(); + int playerBudget = Math.min(MAX_CHUNKS_PER_PLAYER_TICK, MAX_IN_FLIGHT_PER_PLAYER - inFlight); + long playerBytes = 0L; + List subscriptions = playerEntry.getValue().subscriptions.values().stream().sorted(SUBSCRIPTION_COMPARATOR).toList(); + if (subscriptions.isEmpty()) { + continue; + } + int start = Math.floorMod(playerEntry.getValue().loadCursor, subscriptions.size()); + int idleSubscriptions = 0; + for (int offset = 0; idleSubscriptions < subscriptions.size() + && playerBudget > 0 && globalBudget > 0 + && playerBytes < MAX_CHUNK_BYTES_PER_PLAYER_TICK + && globalBytes < MAX_CHUNK_BYTES_GLOBAL_TICK; offset++) { + Subscription subscription = subscriptions.get((start + offset) % subscriptions.size()); + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + if (level == null || subscription.grantedRadius == 0) { + idleSubscriptions++; + continue; + } + LevelChunk chunk = pollReadyChunk(server, state, level, subscription); + if (chunk == null) { + idleSubscriptions++; + continue; + } + PreparedChunkCache.PreparedChunk prepared = state.preparedChunks.find(level, chunk); + if (prepared == null) { + subscription.pendingChunks.add(chunk.getPos()); + idleSubscriptions++; + continue; + } + S2CRemoteChunkPacket packet = prepared.packet(subscription.id, subscription.nextSequence()); + long packetBytes = packet.terrainAndLightBytes(); + if (playerBytes + packetBytes > MAX_CHUNK_BYTES_PER_PLAYER_TICK + || globalBytes + packetBytes > MAX_CHUNK_BYTES_GLOBAL_TICK) { + subscription.pendingChunks.add(chunk.getPos()); + idleSubscriptions++; + continue; + } + send(player, packet); + subscription.trace.transferredChunk(); + subscription.sentChunks.add(chunk.getPos()); + subscription.inFlight.put(chunk.getPos(), new InFlight(packet.sequence(), server.getTickCount())); + subscription.refreshChunks.remove(chunk.getPos()); + playerEntry.getValue().loadCursor = (start + offset + 1) % subscriptions.size(); + idleSubscriptions = 0; + playerBytes += packetBytes; + globalBytes += packetBytes; + playerBudget--; + globalBudget--; + } + } + state.playerLoadCursor = (playerStart + Math.max(1, visitedPlayers)) % players.size(); + } + + private static LevelChunk pollReadyChunk(MinecraftServer server, ServerState state, ServerLevel level, Subscription subscription) { + if (subscription.pendingChunks.isEmpty()) { + return null; + } + List deferred = new ArrayList<>(MAX_LOAD_PROBES); + LevelChunk result = null; + for (int i = 0; i < MAX_LOAD_PROBES && !subscription.pendingChunks.isEmpty(); i++) { + ChunkPos pos = subscription.pendingChunks.poll(); + if (!subscription.interest.contains(pos)) { + continue; + } + if (!subscription.refreshChunks.contains(pos) && reuseChunk(server, state, subscription, level, pos)) { + continue; + } + if (chunkInFlight(state, subscription, pos)) { + deferred.add(pos); + continue; + } + LevelChunk chunk = ((ChunkMapAccessor) level.getChunkSource().chunkMap).glass$getChunkToSend(pos.pack()); + if (chunk != null && chunk.isLightCorrect()) { + result = chunk; + break; + } + deferred.add(pos); + } + subscription.pendingChunks.addAll(deferred); + return result; + } + + private static void preparePendingChunks(MinecraftServer server, ServerState state) { + long started = System.nanoTime(); + int prepared = 0; + List subscriptions = state.players.values().stream() + .flatMap(player -> player.subscriptions.values().stream()) + .filter(subscription -> subscription.grantedRadius > 0 && !subscription.pendingChunks.isEmpty()) + .sorted(SUBSCRIPTION_COMPARATOR).toList(); + if (subscriptions.isEmpty()) { + return; + } + int start = Math.floorMod(state.preparationCursor++, subscriptions.size()); + for (int i = 0; i < subscriptions.size(); i++) { + Subscription subscription = subscriptions.get((start + i) % subscriptions.size()); + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + if (level == null) { + continue; + } + for (ChunkPos pos : subscription.pendingChunks.stream().sorted(chunkComparator(subscription.grantedCenter)).limit(16).toList()) { + if (prepared >= MAX_PREPARED_CHUNKS_PER_TICK || System.nanoTime() - started >= PREPARATION_BUDGET_NANOS) { + return; + } + if (!subscription.refreshChunks.contains(pos) && reuseChunk(server, state, subscription, level, pos)) { + subscription.pendingChunks.remove(pos); + continue; + } + LevelChunk chunk = ((ChunkMapAccessor) level.getChunkSource().chunkMap).glass$getChunkToSend(pos.pack()); + if (chunk == null || !chunk.isLightCorrect() || state.preparedChunks.find(level, chunk) != null) { + continue; + } + try { + state.preparedChunks.prepare(level, chunk); + } catch (IllegalArgumentException exception) { + RemoteStreamTrace.chunkFailed(subscription.id, pos, exception); + invalidateSubscription(server, state, subscription, S2CRemoteUnavailablePacket.Reason.UNSUPPORTED); + return; + } + prepared++; + } + } + } + + private static void sendWorldStates(MinecraftServer server, ServerState state) { + for (PlayerState playerState : state.players.values()) { + for (Subscription subscription : playerState.subscriptions.values()) { + if (subscription.grantedRadius == 0) { + continue; + } + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + if (level != null) { + sendToSubscription(server, subscription, new S2CRemoteWorldStatePacket(subscription.id, subscription.nextSequence(), worldState(level))); + } + } + } + } + + private static RemoteWorldState worldState(ServerLevel level) { + return new RemoteWorldState( + level.getServer().clockManager().createFullSyncPacket(), + level.getRainLevel(1.0F), + level.getThunderLevel(1.0F), + 0 + ); + } + + public static void removePlayer(MinecraftServer server, ServerPlayer player, boolean notify) { + if (!server.isSameThread()) { + server.execute(() -> removePlayer(server, player, notify)); + return; + } + ServerState state = STATES.get(server); + if (state == null) { + return; + } + PlayerState removed = state.players.remove(player.getUUID()); + if (removed == null) { + return; + } + for (Subscription subscription : removed.subscriptions.values()) { + closeSubscription(server, state, player, subscription, S2CRemoteUnavailablePacket.Reason.ENDED, notify); + } + rebalance(server, state); + } + + public static void unloadWorld(MinecraftServer server, ServerLevel world) { + ServerState state = STATES.get(server); + if (state == null) { + return; + } + state.preparedChunks.unload(world.dimension()); + List removals = new ArrayList<>(); + for (Map.Entry playerEntry : state.players.entrySet()) { + for (Subscription subscription : playerEntry.getValue().subscriptions.values()) { + if (subscription.id.source().dimension().equals(world.dimension()) || subscription.id.projectorDimension().equals(world.dimension())) { + removals.add(new Removal(playerEntry.getKey(), subscription, S2CRemoteUnavailablePacket.Reason.ENDED)); + } + } + } + remove(server, state, removals); + rebalance(server, state); + } + + public static void stop(MinecraftServer server) { + ServerState state = STATES.remove(server); + if (state == null) { + return; + } + for (Map.Entry playerEntry : state.players.entrySet()) { + ServerPlayer player = server.getPlayerList().getPlayer(playerEntry.getKey()); + for (Subscription subscription : playerEntry.getValue().subscriptions.values()) { + closeSubscription(server, state, player, subscription, S2CRemoteUnavailablePacket.Reason.ENDED, false); + } + } + } + + private static void remove(MinecraftServer server, ServerState state, List removals) { + for (Removal removal : removals) { + PlayerState playerState = state.players.get(removal.player); + if (playerState != null && playerState.subscriptions.remove(removal.subscription.id) != null) { + closeSubscription(server, state, server.getPlayerList().getPlayer(removal.player), removal.subscription, removal.reason, true); + } + } + } + + private static void closeSubscription(MinecraftServer server, ServerState state, ServerPlayer player, Subscription subscription, S2CRemoteUnavailablePacket.Reason reason, boolean notify) { + subscription.trace.closed(subscription.summary(server.getTickCount()), reason, notify, + reason == S2CRemoteUnavailablePacket.Reason.INVALID ? denial(server, player, subscription.id, subscription.requestedCenter) : null); + ServerLevel level = server.getLevel(subscription.id.source().dimension()); + if (level != null) { + for (Map.Entry entry : subscription.tickets.entrySet()) { + releaseTicket(state, level, entry.getValue()); + } + } + subscription.tickets.clear(); + subscription.interest.clear(); + subscription.pendingChunks.clear(); + subscription.sentChunks.clear(); + subscription.acknowledgedChunks.clear(); + subscription.inFlight.clear(); + subscription.refreshChunks.clear(); + subscription.blockChanges.clear(); + subscription.lightChanges.clear(); + subscription.resyncChunks.clear(); + subscription.entities.clear(); + subscription.entityPackets.clear(); + subscription.blockEntities.clear(); + if (notify) { + send(player, new S2CRemoteUnavailablePacket(subscription.id, reason)); + } + } + + private static void invalidateSubscription(MinecraftServer server, ServerState state, Subscription subscription, S2CRemoteUnavailablePacket.Reason reason) { + PlayerState playerState = state.players.get(subscription.player); + if (playerState != null && playerState.subscriptions.remove(subscription.id) != null) { + closeSubscription(server, state, server.getPlayerList().getPlayer(subscription.player), subscription, reason, true); + state.rebalanceNeeded = true; + } + } + + private static void sendToSubscription(MinecraftServer server, Subscription subscription, CustomPacketPayload payload) { + send(server.getPlayerList().getPlayer(subscription.player), payload); + } + + private static void send(ServerPlayer player, CustomPacketPayload payload) { + if (player != null && PlatformNetworking.canSend(player, payload.type())) { + PlatformNetworking.send(player, payload); + } + } + + private static int chunkDistance(ChunkPos first, ChunkPos second) { + long x = Math.abs((long) first.x() - second.x()); + long z = Math.abs((long) first.z() - second.z()); + return (int) Math.min(Integer.MAX_VALUE, Math.max(x, z)); + } + + private static boolean safeCenter(ChunkPos center) { + int margin = MAX_RADIUS + 1; + return Math.abs((long) center.x()) <= MAX_CHUNK_COORDINATE - margin + && Math.abs((long) center.z()) <= MAX_CHUNK_COORDINATE - margin; + } + + private static Comparator chunkComparator(ChunkPos center) { + return Comparator.comparingInt((ChunkPos chunk) -> chunkDistance(center, chunk)) + .thenComparingInt(chunk -> chunk.x()) + .thenComparingInt(chunk -> chunk.z()); + } + + private static final Comparator SUBSCRIPTION_COMPARATOR = Comparator + .comparing((Subscription subscription) -> subscription.player) + .thenComparing(subscription -> subscription.id.projectorDimension().identifier().toString()) + .thenComparingLong(subscription -> subscription.id.projectorPos().asLong()) + .thenComparing(subscription -> subscription.id.source().channel()) + .thenComparingLong(subscription -> subscription.id.epoch()); + + private enum Denial { + PLAYER_DIMENSION, + UNSAFE_CENTER, + PROJECTOR_UNLOADED, + SOURCE_LEVEL_MISSING, + VIEWER_NOT_AUTHORIZED, + STALE_SOURCE, + NOT_TRACKING, + SOURCE_TOO_FAR; + + @Override + public String toString() { + return name().toLowerCase(Locale.ROOT).replace('_', '-'); + } + } + + private static final class ServerState { + private final Map players = new HashMap<>(); + private final PreparedChunkCache preparedChunks = new PreparedChunkCache(); + private final Map ticketReferences = new HashMap<>(); + private int playerLoadCursor; + private int preparationCursor; + private boolean rebalanceNeeded; + } + + private static final class PlayerState { + private final Map subscriptions = new LinkedHashMap<>(); + private long latestEpoch = -1L; + private int loadCursor; + } + + private static final class Subscription { + private final UUID player; + private final RemoteSubscriptionId id; + private final RemoteStreamTrace trace; + private final Set interest = new HashSet<>(); + private final Set sentChunks = new HashSet<>(); + private final Set acknowledgedChunks = new HashSet<>(); + private final Map inFlight = new HashMap<>(); + private final Set refreshChunks = new HashSet<>(); + private final Map tickets = new HashMap<>(); + private final Map> blockChanges = new LinkedHashMap<>(); + private final Map lightChanges = new LinkedHashMap<>(); + private final Set resyncChunks = new LinkedHashSet<>(); + private final Set entities = new HashSet<>(); + private final Set blockEntities = new LinkedHashSet<>(); + private final ArrayDeque entityPackets = new ArrayDeque<>(); + private boolean entityOverflow; + private ChunkPos requestedCenter; + private ChunkPos grantedCenter; + private PriorityQueue pendingChunks; + private int requestedRadius; + private int grantedRadius; + private int lastSeenTick; + private long sequence; + private boolean budgetUnavailable; + + private Subscription(UUID player, RemoteSubscriptionId id, ChunkPos center, int requestedRadius, int now) { + this.player = player; + this.id = id; + this.trace = new RemoteStreamTrace(now); + this.requestedCenter = center; + this.grantedCenter = center; + this.pendingChunks = new PriorityQueue<>(chunkComparator(center)); + this.requestedRadius = requestedRadius; + this.grantedRadius = -1; + this.lastSeenTick = now; + } + + private long nextSequence() { + return sequence++; + } + + private RemoteStreamTrace.Summary summary(int now) { + return new RemoteStreamTrace.Summary(id, grantedCenter, grantedRadius, sentChunks.size(), interest.size(), + pendingChunks, tickets.size(), now - lastSeenTick, sequence, inFlight.size()); + } + } + + private static final class LightChanges { + private final BitSet sky = new BitSet(); + private final BitSet block = new BitSet(); + } + + private record InFlight(long sequence, int sentTick) { + } + + private record TicketKey(ResourceKey dimension, long chunk) { + } + + private record Removal(UUID player, Subscription subscription, S2CRemoteUnavailablePacket.Reason reason) { + } + + private RemoteSceneServerManager() { + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/RemoteSubscriptionId.java b/glass/src/main/java/dev/imb11/sync/remote/RemoteSubscriptionId.java new file mode 100644 index 0000000..8bd6c16 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/RemoteSubscriptionId.java @@ -0,0 +1,53 @@ +package dev.imb11.sync.remote; + +import dev.imb11.sync.ChannelManagerPersistence; +import dev.imb11.sync.ProjectionSource; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; + +import java.util.Objects; + +public record RemoteSubscriptionId(ResourceKey projectorDimension, BlockPos projectorPos, boolean preview, ProjectionSource source, long epoch) { + public RemoteSubscriptionId { + projectorDimension = Objects.requireNonNull(projectorDimension); + projectorPos = Objects.requireNonNull(projectorPos).immutable(); + source = Objects.requireNonNull(source); + if (epoch < 0L) { + throw new IllegalArgumentException("epoch must be non-negative"); + } + } + + public boolean sameProjector(RemoteSubscriptionId other) { + return projectorDimension.equals(other.projectorDimension) && projectorPos.equals(other.projectorPos) && preview == other.preview + && (!preview || source.channel().equals(other.source.channel())); + } + + public void write(RegistryFriendlyByteBuf buf) { + buf.writeIdentifier(projectorDimension.identifier()); + buf.writeBlockPos(projectorPos); + buf.writeBoolean(preview); + buf.writeUtf(source.channel(), ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + buf.writeIdentifier(source.dimension().identifier()); + buf.writeBlockPos(source.pos()); + buf.writeEnum(source.facing()); + buf.writeVarLong(source.revision()); + buf.writeVarLong(epoch); + } + + public static RemoteSubscriptionId read(RegistryFriendlyByteBuf buf) { + ResourceKey projectorDimension = ResourceKey.create(Registries.DIMENSION, buf.readIdentifier()); + BlockPos projectorPos = buf.readBlockPos(); + boolean preview = buf.readBoolean(); + String channel = buf.readUtf(ChannelManagerPersistence.MAX_WIRE_CHANNEL_NAME_LENGTH); + ResourceKey sourceDimension = ResourceKey.create(Registries.DIMENSION, buf.readIdentifier()); + BlockPos sourcePos = buf.readBlockPos(); + Direction facing = buf.readEnum(Direction.class); + long revision = buf.readVarLong(); + long epoch = buf.readVarLong(); + return new RemoteSubscriptionId(projectorDimension, projectorPos, preview, new ProjectionSource(channel, sourceDimension, sourcePos, facing, revision), epoch); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/RemoteWorldState.java b/glass/src/main/java/dev/imb11/sync/remote/RemoteWorldState.java new file mode 100644 index 0000000..c49c35d --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/RemoteWorldState.java @@ -0,0 +1,23 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.game.ClientboundSetTimePacket; + +public record RemoteWorldState(ClientboundSetTimePacket time, float rainLevel, float thunderLevel, int skyFlashTime) { + public RemoteWorldState { + rainLevel = Math.clamp(rainLevel, 0.0F, 1.0F); + thunderLevel = Math.clamp(thunderLevel, 0.0F, 1.0F); + skyFlashTime = Math.max(0, skyFlashTime); + } + + public void write(RegistryFriendlyByteBuf buf) { + ClientboundSetTimePacket.STREAM_CODEC.encode(buf, time); + buf.writeFloat(rainLevel); + buf.writeFloat(thunderLevel); + buf.writeVarInt(skyFlashTime); + } + + public static RemoteWorldState read(RegistryFriendlyByteBuf buf) { + return new RemoteWorldState(ClientboundSetTimePacket.STREAM_CODEC.decode(buf), buf.readFloat(), buf.readFloat(), buf.readVarInt()); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteBlockEntitiesPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteBlockEntitiesPacket.java new file mode 100644 index 0000000..2640a5b --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteBlockEntitiesPacket.java @@ -0,0 +1,51 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.List; + +public record S2CRemoteBlockEntitiesPacket(RemoteSubscriptionId subscription, long sequence, List updates) implements CustomPacketPayload { + public static final int MAX_UPDATES = 32; + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_block_entities")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteBlockEntitiesPacket::write, S2CRemoteBlockEntitiesPacket::read); + + public S2CRemoteBlockEntitiesPacket { + updates = List.copyOf(updates); + if (sequence < 0L || updates.isEmpty() || updates.size() > MAX_UPDATES) { + throw new IllegalArgumentException("invalid remote block entity batch"); + } + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + buf.writeVarInt(updates.size()); + for (ClientboundBlockEntityDataPacket update : updates) { + ClientboundBlockEntityDataPacket.STREAM_CODEC.encode(buf, update); + } + } + + private static S2CRemoteBlockEntitiesPacket read(RegistryFriendlyByteBuf buf) { + RemoteSubscriptionId subscription = RemoteSubscriptionId.read(buf); + long sequence = buf.readVarLong(); + int size = buf.readVarInt(); + if (size < 1 || size > MAX_UPDATES) { + throw new IllegalArgumentException("invalid remote block entity batch size"); + } + List updates = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + updates.add(ClientboundBlockEntityDataPacket.STREAM_CODEC.decode(buf)); + } + return new S2CRemoteBlockEntitiesPacket(subscription, sequence, updates); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteBlockUpdatesPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteBlockUpdatesPacket.java new file mode 100644 index 0000000..2e018b7 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteBlockUpdatesPacket.java @@ -0,0 +1,39 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket; +import net.minecraft.resources.Identifier; + +public record S2CRemoteBlockUpdatesPacket(RemoteSubscriptionId subscription, long sequence, + int sectionX, int sectionY, int sectionZ, + ClientboundSectionBlocksUpdatePacket updates) implements CustomPacketPayload { + public static final int MAX_UPDATES = 64; + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_block_updates")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteBlockUpdatesPacket::write, S2CRemoteBlockUpdatesPacket::read); + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + buf.writeInt(sectionX); + buf.writeInt(sectionY); + buf.writeInt(sectionZ); + ClientboundSectionBlocksUpdatePacket.STREAM_CODEC.encode(buf, updates); + } + + private static S2CRemoteBlockUpdatesPacket read(RegistryFriendlyByteBuf buf) { + RemoteSubscriptionId subscription = RemoteSubscriptionId.read(buf); + long sequence = buf.readVarLong(); + int x = buf.readInt(); + int y = buf.readInt(); + int z = buf.readInt(); + return new S2CRemoteBlockUpdatesPacket(subscription, sequence, x, y, z, + ClientboundSectionBlocksUpdatePacket.STREAM_CODEC.decode(buf)); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteChunkPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteChunkPacket.java new file mode 100644 index 0000000..8be716e --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteChunkPacket.java @@ -0,0 +1,40 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; + +public record S2CRemoteChunkPacket(RemoteSubscriptionId subscription, long sequence, ClientboundLevelChunkWithLightPacket data, int encodedBytes) implements CustomPacketPayload { + public static final int MAX_BYTES = 983040; + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_chunk")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteChunkPacket::write, S2CRemoteChunkPacket::read); + + @Override + public Type type() { + return PACKET_ID; + } + + public long terrainAndLightBytes() { + return encodedBytes; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + ClientboundLevelChunkWithLightPacket.STREAM_CODEC.encode(buf, data); + } + + private static S2CRemoteChunkPacket read(RegistryFriendlyByteBuf buf) { + RemoteSubscriptionId subscription = RemoteSubscriptionId.read(buf); + long sequence = buf.readVarLong(); + int start = buf.readerIndex(); + if (buf.readableBytes() > MAX_BYTES) { + throw new IllegalArgumentException("Projection chunk exceeds packet limit"); + } + ClientboundLevelChunkWithLightPacket data = ClientboundLevelChunkWithLightPacket.STREAM_CODEC.decode(buf); + return new S2CRemoteChunkPacket(subscription, sequence, data, buf.readerIndex() - start); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteEntitiesPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteEntitiesPacket.java new file mode 100644 index 0000000..32c0464 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteEntitiesPacket.java @@ -0,0 +1,100 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.game.*; +import net.minecraft.resources.Identifier; + +import java.util.List; + +public record S2CRemoteEntitiesPacket(RemoteSubscriptionId subscription, long sequence, List messages) implements CustomPacketPayload { + public static final int MAX_MESSAGES = 128; + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_entities")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteEntitiesPacket::write, S2CRemoteEntitiesPacket::read); + private static final List> ENTRIES = List.of( + new Entry<>(ClientboundAddEntityPacket.class, ClientboundAddEntityPacket.STREAM_CODEC), + new Entry<>(ClientboundEntityPositionSyncPacket.class, ClientboundEntityPositionSyncPacket.STREAM_CODEC), + new Entry<>(ClientboundSetEntityDataPacket.class, ClientboundSetEntityDataPacket.STREAM_CODEC), + new Entry<>(ClientboundMoveEntityPacket.Pos.class, ClientboundMoveEntityPacket.Pos.STREAM_CODEC), + new Entry<>(ClientboundMoveEntityPacket.Rot.class, ClientboundMoveEntityPacket.Rot.STREAM_CODEC), + new Entry<>(ClientboundMoveEntityPacket.PosRot.class, ClientboundMoveEntityPacket.PosRot.STREAM_CODEC), + new Entry<>(ClientboundTeleportEntityPacket.class, ClientboundTeleportEntityPacket.STREAM_CODEC), + new Entry<>(ClientboundRotateHeadPacket.class, ClientboundRotateHeadPacket.STREAM_CODEC), + new Entry<>(ClientboundSetEntityMotionPacket.class, ClientboundSetEntityMotionPacket.STREAM_CODEC), + new Entry<>(ClientboundSetEquipmentPacket.class, ClientboundSetEquipmentPacket.STREAM_CODEC), + new Entry<>(ClientboundUpdateAttributesPacket.class, ClientboundUpdateAttributesPacket.STREAM_CODEC), + new Entry<>(ClientboundSetPassengersPacket.class, ClientboundSetPassengersPacket.STREAM_CODEC), + new Entry<>(ClientboundSetEntityLinkPacket.class, ClientboundSetEntityLinkPacket.STREAM_CODEC), + new Entry<>(ClientboundAnimatePacket.class, ClientboundAnimatePacket.STREAM_CODEC), + new Entry<>(ClientboundHurtAnimationPacket.class, ClientboundHurtAnimationPacket.STREAM_CODEC), + new Entry<>(ClientboundEntityEventPacket.class, ClientboundEntityEventPacket.STREAM_CODEC), + new Entry<>(ClientboundUpdateMobEffectPacket.class, ClientboundUpdateMobEffectPacket.STREAM_CODEC), + new Entry<>(ClientboundRemoveMobEffectPacket.class, ClientboundRemoveMobEffectPacket.STREAM_CODEC), + new Entry<>(ClientboundRemoveEntitiesPacket.class, ClientboundRemoveEntitiesPacket.STREAM_CODEC) + ); + + public S2CRemoteEntitiesPacket { + messages = List.copyOf(messages); + if (sequence < 0L || messages.isEmpty() || messages.size() > MAX_MESSAGES) { + throw new IllegalArgumentException("invalid remote entity batch"); + } + } + + @Override + public Type type() { + return PACKET_ID; + } + + public static boolean supports(Packet packet) { + return ENTRIES.stream().anyMatch(entry -> entry.packetClass().isInstance(packet)); + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + buf.writeVarInt(messages.size()); + for (EntityMessage message : messages) { + buf.writeVarInt(message.entityId()); + int index = 0; + while (index < ENTRIES.size() && !ENTRIES.get(index).packetClass().isInstance(message.packet())) { + index++; + } + if (index == ENTRIES.size()) { + throw new IllegalArgumentException("unsupported remote entity packet"); + } + buf.writeVarInt(index); + ENTRIES.get(index).write(buf, message.packet()); + } + } + + private static S2CRemoteEntitiesPacket read(RegistryFriendlyByteBuf buf) { + RemoteSubscriptionId subscription = RemoteSubscriptionId.read(buf); + long sequence = buf.readVarLong(); + int count = buf.readVarInt(); + if (count < 1 || count > MAX_MESSAGES) { + throw new IllegalArgumentException("invalid remote entity batch size"); + } + java.util.ArrayList messages = new java.util.ArrayList<>(count); + for (int i = 0; i < count; i++) { + int entityId = buf.readVarInt(); + int type = buf.readVarInt(); + if (type < 0 || type >= ENTRIES.size()) { + throw new IllegalArgumentException("invalid remote entity packet type"); + } + messages.add(new EntityMessage(entityId, ENTRIES.get(type).codec().decode(buf))); + } + return new S2CRemoteEntitiesPacket(subscription, sequence, messages); + } + + public record EntityMessage(int entityId, Packet packet) { + } + + private record Entry>(Class packetClass, StreamCodec codec) { + private void write(RegistryFriendlyByteBuf buf, Packet packet) { + codec.encode(buf, packetClass.cast(packet)); + } + } +} + diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteLightPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteLightPacket.java new file mode 100644 index 0000000..15e1fd0 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteLightPacket.java @@ -0,0 +1,30 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.game.ClientboundLightUpdatePacket; +import net.minecraft.resources.Identifier; + +public record S2CRemoteLightPacket(RemoteSubscriptionId subscription, long sequence, + ClientboundLightUpdatePacket data) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_light")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteLightPacket::write, S2CRemoteLightPacket::read); + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + ClientboundLightUpdatePacket.STREAM_CODEC.encode(buf, data); + } + + private static S2CRemoteLightPacket read(RegistryFriendlyByteBuf buf) { + RemoteSubscriptionId subscription = RemoteSubscriptionId.read(buf); + long sequence = buf.readVarLong(); + return new S2CRemoteLightPacket(subscription, sequence, ClientboundLightUpdatePacket.STREAM_CODEC.decode(buf)); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteSubscriptionPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteSubscriptionPacket.java new file mode 100644 index 0000000..4ff1eaf --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteSubscriptionPacket.java @@ -0,0 +1,63 @@ +package dev.imb11.sync.remote; + +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.Identifier; +import net.minecraft.world.Difficulty; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.dimension.DimensionType; + +import java.util.Objects; + +public record S2CRemoteSubscriptionPacket(RemoteSubscriptionId subscription, ChunkPos grantedCenter, int grantedRadius, ResourceKey dimensionType, Difficulty difficulty, boolean hardcore, boolean debug, boolean flat, long seedHash, int seaLevel, RemoteWorldState state) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_subscription")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteSubscriptionPacket::write, S2CRemoteSubscriptionPacket::read); + + public S2CRemoteSubscriptionPacket { + subscription = Objects.requireNonNull(subscription); + grantedCenter = Objects.requireNonNull(grantedCenter); + dimensionType = Objects.requireNonNull(dimensionType); + difficulty = Objects.requireNonNull(difficulty); + state = Objects.requireNonNull(state); + if (grantedRadius < 0 || grantedRadius > 6) { + throw new IllegalArgumentException("invalid granted radius"); + } + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeInt(grantedCenter.x()); + buf.writeInt(grantedCenter.z()); + buf.writeVarInt(grantedRadius); + buf.writeIdentifier(dimensionType.identifier()); + buf.writeEnum(difficulty); + buf.writeBoolean(hardcore); + buf.writeBoolean(debug); + buf.writeBoolean(flat); + buf.writeLong(seedHash); + buf.writeInt(seaLevel); + state.write(buf); + } + + private static S2CRemoteSubscriptionPacket read(RegistryFriendlyByteBuf buf) { + RemoteSubscriptionId subscription = RemoteSubscriptionId.read(buf); + ChunkPos grantedCenter = new ChunkPos(buf.readInt(), buf.readInt()); + int grantedRadius = buf.readVarInt(); + ResourceKey dimensionType = ResourceKey.create(Registries.DIMENSION_TYPE, buf.readIdentifier()); + Difficulty difficulty = buf.readEnum(Difficulty.class); + boolean hardcore = buf.readBoolean(); + boolean debug = buf.readBoolean(); + boolean flat = buf.readBoolean(); + long seedHash = buf.readLong(); + int seaLevel = buf.readInt(); + return new S2CRemoteSubscriptionPacket(subscription, grantedCenter, grantedRadius, dimensionType, difficulty, hardcore, debug, flat, seedHash, seaLevel, RemoteWorldState.read(buf)); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteUnavailablePacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteUnavailablePacket.java new file mode 100644 index 0000000..8ecec73 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteUnavailablePacket.java @@ -0,0 +1,40 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import java.util.Objects; + +public record S2CRemoteUnavailablePacket(RemoteSubscriptionId subscription, Reason reason) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_unavailable")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteUnavailablePacket::write, S2CRemoteUnavailablePacket::read); + + public S2CRemoteUnavailablePacket { + subscription = Objects.requireNonNull(subscription); + reason = Objects.requireNonNull(reason); + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeEnum(reason); + } + + private static S2CRemoteUnavailablePacket read(RegistryFriendlyByteBuf buf) { + return new S2CRemoteUnavailablePacket(RemoteSubscriptionId.read(buf), buf.readEnum(Reason.class)); + } + + public enum Reason { + INVALID, + BUDGET, + TIMEOUT, + ENDED, + UNSUPPORTED + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteUnloadPacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteUnloadPacket.java new file mode 100644 index 0000000..9cbd955 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteUnloadPacket.java @@ -0,0 +1,36 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import java.util.Objects; + +public record S2CRemoteUnloadPacket(RemoteSubscriptionId subscription, long sequence, int chunkX, int chunkZ) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_unload")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteUnloadPacket::write, S2CRemoteUnloadPacket::read); + + public S2CRemoteUnloadPacket { + subscription = Objects.requireNonNull(subscription); + if (sequence < 0L) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + buf.writeInt(chunkX); + buf.writeInt(chunkZ); + } + + private static S2CRemoteUnloadPacket read(RegistryFriendlyByteBuf buf) { + return new S2CRemoteUnloadPacket(RemoteSubscriptionId.read(buf), buf.readVarLong(), buf.readInt(), buf.readInt()); + } +} diff --git a/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteWorldStatePacket.java b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteWorldStatePacket.java new file mode 100644 index 0000000..9f981b2 --- /dev/null +++ b/glass/src/main/java/dev/imb11/sync/remote/S2CRemoteWorldStatePacket.java @@ -0,0 +1,36 @@ +package dev.imb11.sync.remote; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import java.util.Objects; + +public record S2CRemoteWorldStatePacket(RemoteSubscriptionId subscription, long sequence, RemoteWorldState state) implements CustomPacketPayload { + public static final Type PACKET_ID = new Type<>(Identifier.fromNamespaceAndPath("glass", "remote_world_state")); + public static final StreamCodec PACKET_CODEC = StreamCodec.ofMember(S2CRemoteWorldStatePacket::write, S2CRemoteWorldStatePacket::read); + + public S2CRemoteWorldStatePacket { + subscription = Objects.requireNonNull(subscription); + state = Objects.requireNonNull(state); + if (sequence < 0L) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + } + + @Override + public Type type() { + return PACKET_ID; + } + + private void write(RegistryFriendlyByteBuf buf) { + subscription.write(buf); + buf.writeVarLong(sequence); + state.write(buf); + } + + private static S2CRemoteWorldStatePacket read(RegistryFriendlyByteBuf buf) { + return new S2CRemoteWorldStatePacket(RemoteSubscriptionId.read(buf), buf.readVarLong(), RemoteWorldState.read(buf)); + } +} diff --git a/glass/src/main/resources/META-INF/accesstransformer.cfg b/glass/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..5e8b25a --- /dev/null +++ b/glass/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ + +public net.minecraft.world.level.block.entity.BlockEntityType$BlockEntitySupplier +public net.minecraft.world.level.block.entity.BlockEntityType (Lnet/minecraft/world/level/block/entity/BlockEntityType$BlockEntitySupplier;Ljava/util/Set;)V diff --git a/glass/src/main/resources/META-INF/neoforge.mods.toml b/glass/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..5b6138d --- /dev/null +++ b/glass/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,32 @@ +modLoader="javafml" +loaderVersion="[4,)" +license="All-Rights-Reserved" + +[[mods]] +modId="glass" +version="${version}" +displayName="${mod_name}" +authors="IMB11" +credits="Block models and textures by thepigcat" +description="${mod_description}" +logoFile="assets/glass/icon.png" + +[[mixins]] +config="glass.mixins.json" + +[[dependencies.glass]] +modId="neoforge" +type="required" +versionRange="${target_loader}" +ordering="NONE" +side="BOTH" + +[[dependencies.glass]] +modId="minecraft" +type="required" +versionRange="${target_minecraft}" +ordering="NONE" +side="BOTH" + +[features.glass] +javaVersion="[25,)" diff --git a/glass/src/main/resources/assets/glass/icon.png b/glass/src/main/resources/assets/glass/icon.png new file mode 100644 index 0000000..de61908 Binary files /dev/null and b/glass/src/main/resources/assets/glass/icon.png differ diff --git a/glass/src/main/resources/assets/glass/models/block/projector_assembly.json b/glass/src/main/resources/assets/glass/models/block/projector_assembly.json new file mode 100644 index 0000000..af8a5ab --- /dev/null +++ b/glass/src/main/resources/assets/glass/models/block/projector_assembly.json @@ -0,0 +1,102 @@ +{ + "format_version": "1.21.6", + "credit": "Made with Blockbench", + "parent": "minecraft:block/block", + "textures": { + "4": "glass:block/powerable_glass_side", + "5": "glass:block/powerable_glass", + "6": "glass:block/projector", + "7": "glass:block/projector1", + "particle": "glass:block/powerable_glass" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [0, 9, 16], + "faces": { + "north": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "east": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "west"}, + "south": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "west": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "west"}, + "up": {"uv": [0, 0, 0, 16], "texture": "#4"}, + "down": {"uv": [0, 0, 0, 16], "texture": "#4"} + } + }, + { + "from": [16, 0, 0], + "to": [16, 9, 16], + "rotation": {"angle": 0, "axis": "y", "origin": [16, 0, 0]}, + "faces": { + "north": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "east": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "east"}, + "south": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "west": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "east"}, + "up": {"uv": [0, 0, 0, 16], "texture": "#4"}, + "down": {"uv": [0, 0, 0, 16], "texture": "#4"} + } + }, + { + "from": [0, 0, 0], + "to": [16, 9, 0], + "faces": { + "north": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "north"}, + "east": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "south": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "north"}, + "west": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "up": {"uv": [0, 0, 16, 0], "texture": "#4"}, + "down": {"uv": [0, 0, 16, 0], "texture": "#4"} + } + }, + { + "from": [0, 0, 16], + "to": [16, 9, 16], + "rotation": {"angle": 0, "axis": "y", "origin": [0, 0, 16]}, + "faces": { + "north": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "south"}, + "east": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "south": {"uv": [0, 7, 16, 16], "texture": "#4", "cullface": "south"}, + "west": {"uv": [0, 0, 0, 8], "texture": "#4"}, + "up": {"uv": [0, 0, 16, 0], "texture": "#4"}, + "down": {"uv": [0, 0, 16, 0], "texture": "#4"} + } + }, + { + "from": [4, 0, 4], + "to": [12, 8, 12], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 0, 7]}, + "faces": { + "north": {"uv": [0, 0, 8, 8], "texture": "#6"}, + "east": {"uv": [0, 0, 8, 8], "texture": "#6"}, + "south": {"uv": [0, 0, 8, 8], "texture": "#6"}, + "west": {"uv": [0, 0, 8, 8], "texture": "#6"}, + "up": {"uv": [8, 0, 16, 8], "texture": "#6"}, + "down": {"uv": [8, 8, 16, 16], "texture": "#6"} + } + }, + { + "from": [5, 8, 5], + "to": [11, 16, 11], + "rotation": {"angle": 0, "axis": "y", "origin": [7, 8, 7]}, + "faces": { + "north": {"uv": [0, 0, 6, 8], "texture": "#7"}, + "east": {"uv": [0, 0, 6, 8], "texture": "#7"}, + "south": {"uv": [0, 0, 6, 8], "texture": "#7"}, + "west": {"uv": [0, 0, 6, 8], "texture": "#7"}, + "up": {"uv": [6, 0, 12, 6], "texture": "#7"}, + "down": {"uv": [0, 0, 6, 6], "texture": "#7"} + } + }, + { + "from": [0, 0, 0], + "to": [16, 0, 16], + "faces": { + "north": {"uv": [0, 0, 16, 0], "texture": "#4"}, + "east": {"uv": [0, 0, 16, 0], "texture": "#4"}, + "south": {"uv": [0, 0, 16, 0], "texture": "#4"}, + "west": {"uv": [0, 0, 16, 0], "texture": "#4"}, + "up": {"uv": [0, 0, 16, 16], "texture": "#5", "cullface": "down"}, + "down": {"uv": [0, 0, 16, 16], "texture": "#5", "cullface": "down"} + } + } + ] +} diff --git a/glass/src/main/resources/assets/glass/models/block/terminal_camera.json b/glass/src/main/resources/assets/glass/models/block/terminal_camera.json new file mode 100644 index 0000000..1c591a2 --- /dev/null +++ b/glass/src/main/resources/assets/glass/models/block/terminal_camera.json @@ -0,0 +1,322 @@ +{ + "format_version": "1.21.6", + "credit": "Made with Blockbench", + "textures": { + "1": "glass:block/terminal", + "particle": "glass:block/terminal" + }, + "elements": [ + { + "from": [0, 0, 0], + "to": [4, 4, 4], + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [0, 0, 12], + "to": [4, 4, 16], + "rotation": {"angle": 0, "axis": "y", "origin": [0, 0, 12]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [12, 0, 12], + "to": [16, 4, 16], + "rotation": {"angle": 0, "axis": "y", "origin": [12, 0, 12]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [12, 4, 12], + "to": [15, 12, 15], + "rotation": {"angle": 0, "axis": "y", "origin": [12, 4, 12]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "up": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "down": {"uv": [0, 0, 3, 3], "texture": "#1"} + } + }, + { + "from": [1, 4, 12], + "to": [4, 12, 15], + "rotation": {"angle": 0, "axis": "y", "origin": [1, 4, 12]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "up": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "down": {"uv": [0, 0, 3, 3], "texture": "#1"} + } + }, + { + "from": [1, 4, 1], + "to": [4, 12, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [1, 4, 1]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "up": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "down": {"uv": [0, 0, 3, 3], "texture": "#1"} + } + }, + { + "from": [12, 4, 1], + "to": [15, 12, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [12, 4, 1]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "up": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "down": {"uv": [0, 0, 3, 3], "texture": "#1"} + } + }, + { + "from": [4, 1, 1], + "to": [12, 4, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [8, 1, 0]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "east": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "west": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "texture": "#1"} + } + }, + { + "from": [12, 0, 0], + "to": [16, 4, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [12, 0, 0]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [4, 12, 1], + "to": [12, 15, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [8, 12, 0]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "east": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "west": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "texture": "#1"} + } + }, + { + "from": [4, 12, 12], + "to": [12, 15, 15], + "rotation": {"angle": 0, "axis": "y", "origin": [8, 12, 11]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "east": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "west": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "texture": "#1"} + } + }, + { + "from": [1, 12, 4], + "to": [4, 15, 12], + "rotation": {"angle": 0, "axis": "y", "origin": [5, 12, 8]}, + "faces": { + "north": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "south": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"} + } + }, + { + "from": [12, 12, 4], + "to": [15, 15, 12], + "rotation": {"angle": 0, "axis": "y", "origin": [16, 12, 8]}, + "faces": { + "north": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "south": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"} + } + }, + { + "from": [12, 1, 4], + "to": [15, 4, 12], + "rotation": {"angle": 0, "axis": "y", "origin": [16, 1, 8]}, + "faces": { + "north": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "south": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"} + } + }, + { + "from": [1, 1, 4], + "to": [4, 4, 12], + "rotation": {"angle": 0, "axis": "y", "origin": [5, 1, 8]}, + "faces": { + "north": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "east": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "south": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "west": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "rotation": 90, "texture": "#1"} + } + }, + { + "from": [4, 1, 12], + "to": [12, 4, 15], + "rotation": {"angle": 0, "axis": "y", "origin": [8, 1, 11]}, + "faces": { + "north": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "east": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "south": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "west": {"uv": [0, 0, 3, 3], "texture": "#1"}, + "up": {"uv": [4, 0, 12, 3], "texture": "#1"}, + "down": {"uv": [4, 0, 12, 3], "texture": "#1"} + } + }, + { + "from": [0, 12, 12], + "to": [4, 16, 16], + "rotation": {"angle": 0, "axis": "y", "origin": [0, 12, 12]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [0, 12, 0], + "to": [4, 16, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [0, 12, 0]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [12, 12, 12], + "to": [16, 16, 16], + "rotation": {"angle": 0, "axis": "y", "origin": [12, 12, 12]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [12, 12, 0], + "to": [16, 16, 4], + "rotation": {"angle": 0, "axis": "y", "origin": [12, 12, 0]}, + "faces": { + "north": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "east": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "south": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "west": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "up": {"uv": [0, 0, 4, 4], "texture": "#1"}, + "down": {"uv": [0, 0, 4, 4], "texture": "#1"} + } + }, + { + "from": [6, 5, 4.5], + "to": [12, 11, 10.5], + "rotation": {"angle": -45, "axis": "y", "origin": [8, 7, 6.5]}, + "faces": { + "north": {"uv": [0, 4, 6, 10], "texture": "#1", "tintindex": 1}, + "east": {"uv": [0, 4, 6, 10], "texture": "#1", "tintindex": 1}, + "south": {"uv": [0, 4, 6, 10], "texture": "#1", "tintindex": 1}, + "west": {"uv": [0, 4, 6, 10], "texture": "#1", "tintindex": 1}, + "up": {"uv": [0, 4, 6, 10], "texture": "#1", "tintindex": 1}, + "down": {"uv": [0, 4, 6, 10], "texture": "#1", "tintindex": 1} + } + } + ], + "display": { + "thirdperson_righthand": { + "rotation": [75, 45, 0], + "translation": [0, 2.5, 0], + "scale": [0.375, 0.375, 0.375] + }, + "thirdperson_lefthand": { + "rotation": [75, 45, 0], + "translation": [0, 2.5, 0], + "scale": [0.375, 0.375, 0.375] + }, + "firstperson_righthand": { + "rotation": [0, 45, 0], + "scale": [0.4, 0.4, 0.4] + }, + "firstperson_lefthand": { + "rotation": [0, -135, 0], + "scale": [0.4, 0.4, 0.4] + }, + "ground": { + "translation": [0, 3, 0], + "scale": [0.25, 0.25, 0.25] + }, + "gui": { + "rotation": [30, -135, 0], + "scale": [0.625, 0.625, 0.625] + }, + "fixed": { + "scale": [0.5, 0.5, 0.5] + } + }, + "groups": [ + { + "name": "frame", + "origin": [0, 0, 0], + "color": 0, + "children": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] + }, + 20 + ] +} diff --git a/glass/src/main/resources/assets/glass/models/item/projector.json b/glass/src/main/resources/assets/glass/models/item/projector.json new file mode 100644 index 0000000..3dde29b --- /dev/null +++ b/glass/src/main/resources/assets/glass/models/item/projector.json @@ -0,0 +1,3 @@ +{ + "parent": "glass:block/projector_assembly" +} diff --git a/glass/src/main/resources/assets/glass/shaders/core/projection_surface.fsh b/glass/src/main/resources/assets/glass/shaders/core/projection_surface.fsh new file mode 100644 index 0000000..80539c3 --- /dev/null +++ b/glass/src/main/resources/assets/glass/shaders/core/projection_surface.fsh @@ -0,0 +1,93 @@ +#version 330 + +uniform sampler2D Projection0; +uniform sampler2D Projection1; +uniform sampler2D Projection2; +uniform sampler2D Projection3; +layout(std140) uniform SurfaceParameters { + vec4 RevealProgress; + vec4 ProjectionChannels; + vec4 ProjectorPositions[4]; + vec4 SurfaceSettings; +}; +#define LoadingOpacity SurfaceSettings.x +#define CrossfadeWidth SurfaceSettings.y + +noperspective in vec2 screenUv; +in vec3 surfacePosition; +flat in vec4 revealDistances; +flat in vec4 projectionMask; + +out vec4 fragColor; + +vec3 projectionColor(int index) { + if (index == 0) { + return texture(Projection0, screenUv).rgb; + } + if (index == 1) { + return texture(Projection1, screenUv).rgb; + } + if (index == 2) { + return texture(Projection2, screenUv).rgb; + } + return texture(Projection3, screenUv).rgb; +} + +void main() { + vec4 visible = projectionMask * step(revealDistances, RevealProgress); + if (LoadingOpacity >= 0.0) { + if (dot(visible, vec4(1.0)) > 0.0) { + discard; + } + fragColor = vec4(1.0, 1.0, 1.0, LoadingOpacity); + return; + } + + if (dot(visible, vec4(1.0)) == 0.0) { + discard; + } + + vec3 positions[4] = vec3[4](ProjectorPositions[0].xyz, ProjectorPositions[1].xyz, ProjectorPositions[2].xyz, ProjectorPositions[3].xyz); + vec4 selected = visible; + vec4 whiteOpacity = clamp(vec4(1.0) - (RevealProgress - revealDistances) * 0.5, 0.0, 1.0); + for (int index = 0; index < 4; index++) { + if (visible[index] == 0.0) { + continue; + } + float distance = length(positions[index] - surfacePosition); + for (int other = 0; other < 4; other++) { + if (other == index || visible[other] == 0.0 || ProjectionChannels[other] != ProjectionChannels[index]) { + continue; + } + whiteOpacity[index] = min(whiteOpacity[index], whiteOpacity[other]); + float otherDistance = length(positions[other] - surfacePosition); + if (otherDistance < distance || (otherDistance == distance && other < index)) { + selected[index] = 0.0; + } + } + } + vec3 color = vec3(0.0); + float totalWeight = 0.0; + for (int index = 0; index < 4; index++) { + if (selected[index] == 0.0) { + continue; + } + float weight = 1.0; + for (int other = 0; other < 4; other++) { + if (other == index || selected[other] == 0.0) { + continue; + } + vec3 separation = positions[other] - positions[index]; + float separationLength = length(separation); + float midpointDistance = separationLength > 0.0001 + ? dot((positions[index] + positions[other]) * 0.5 - surfacePosition, separation / separationLength) + : 0.0; + weight *= smoothstep(-CrossfadeWidth * 0.5, CrossfadeWidth * 0.5, midpointDistance); + } + if (weight > 0.0) { + color += mix(projectionColor(index), vec3(1.0), whiteOpacity[index]) * weight; + totalWeight += weight; + } + } + fragColor = vec4(color / max(totalWeight, 0.0001), 1.0); +} diff --git a/glass/src/main/resources/assets/glass/shaders/core/projection_surface.vsh b/glass/src/main/resources/assets/glass/shaders/core/projection_surface.vsh new file mode 100644 index 0000000..5bb5cd5 --- /dev/null +++ b/glass/src/main/resources/assets/glass/shaders/core/projection_surface.vsh @@ -0,0 +1,22 @@ +#version 330 + +in vec3 Position; +in vec2 UV0; +in vec4 Color; + +#moj_import +#moj_import + +noperspective out vec2 screenUv; +out vec3 surfacePosition; +flat out vec4 revealDistances; +flat out vec4 projectionMask; + +void main() { + vec4 clipPosition = ProjMat * ModelViewMat * vec4(Position, 1.0); + gl_Position = clipPosition; + screenUv = clipPosition.xy / clipPosition.w * 0.5 + 0.5; + surfacePosition = Position; + revealDistances = vec4(mod(UV0.x, 4096.0), floor(UV0.x / 4096.0), mod(UV0.y, 4096.0), floor(UV0.y / 4096.0)); + projectionMask = Color; +} diff --git a/glass/src/main/resources/assets/glass/shaders/core/projector_lens.fsh b/glass/src/main/resources/assets/glass/shaders/core/projector_lens.fsh new file mode 100644 index 0000000..e18b8aa --- /dev/null +++ b/glass/src/main/resources/assets/glass/shaders/core/projector_lens.fsh @@ -0,0 +1,25 @@ +#version 330 + +#moj_import + +uniform sampler2D Sampler0; + +in vec2 lensUv; +in vec2 shimmerUv; +in float vertexDistance; + +out vec4 fragColor; + +void main() { + vec2 edge = min(lensUv, 1.0 - lensUv); + vec2 fade = smoothstep(vec2(0.0), vec2(0.3), edge); + float mask = fade.x * fade.y; + vec2 center = (lensUv - 0.5) * 2.0; + float glow = exp(-3.5 * dot(center, center)); + float pulse = 0.9 + 0.1 * sin(GameTime * 1507.964474); + vec3 shimmer = texture(Sampler0, shimmerUv).rgb; + float brightness = max(shimmer.r, max(shimmer.g, shimmer.b)); + float alpha = mask * (brightness * 0.3 + glow * pulse * 0.16); + vec3 color = mix(vec3(0.38, 0.16, 0.72), vec3(0.65, 0.44, 0.95), glow * 0.65); + fragColor = vec4(color, alpha); +} diff --git a/glass/src/main/resources/assets/glass/shaders/core/projector_lens.vsh b/glass/src/main/resources/assets/glass/shaders/core/projector_lens.vsh new file mode 100644 index 0000000..cf83440 --- /dev/null +++ b/glass/src/main/resources/assets/glass/shaders/core/projector_lens.vsh @@ -0,0 +1,20 @@ +#version 330 + +in vec3 Position; +in vec2 UV0; + +#moj_import +#moj_import +#moj_import + +out vec2 lensUv; +out vec2 shimmerUv; +out float vertexDistance; + +void main() { + vec4 position = ModelViewMat * vec4(Position, 1.0); + gl_Position = ProjMat * position; + vertexDistance = length(position.xyz); + lensUv = UV0; + shimmerUv = UV0 / 8.0 + vec2(GameTime * 16.0, GameTime * 8.0); +} diff --git a/glass/src/main/resources/assets/glass/sounds.json b/glass/src/main/resources/assets/glass/sounds.json new file mode 100644 index 0000000..7042a00 --- /dev/null +++ b/glass/src/main/resources/assets/glass/sounds.json @@ -0,0 +1,18 @@ +{ + "projection.panel_activate": { + "subtitle": "subtitles.glass.projection.panel_activate", + "sounds": [ + "glass:projection/activate_1", + "glass:projection/activate_2", + "glass:projection/activate_3" + ] + }, + "projection.panel_deactivate": { + "subtitle": "subtitles.glass.projection.panel_deactivate", + "sounds": [ + "glass:projection/deactivate_1", + "glass:projection/deactivate_2", + "glass:projection/deactivate_3" + ] + } +} diff --git a/glass/src/main/resources/assets/glass/sounds/projection/activate_1.ogg b/glass/src/main/resources/assets/glass/sounds/projection/activate_1.ogg new file mode 100644 index 0000000..1033ccd Binary files /dev/null and b/glass/src/main/resources/assets/glass/sounds/projection/activate_1.ogg differ diff --git a/glass/src/main/resources/assets/glass/sounds/projection/activate_2.ogg b/glass/src/main/resources/assets/glass/sounds/projection/activate_2.ogg new file mode 100644 index 0000000..d33219a Binary files /dev/null and b/glass/src/main/resources/assets/glass/sounds/projection/activate_2.ogg differ diff --git a/glass/src/main/resources/assets/glass/sounds/projection/activate_3.ogg b/glass/src/main/resources/assets/glass/sounds/projection/activate_3.ogg new file mode 100644 index 0000000..629ec44 Binary files /dev/null and b/glass/src/main/resources/assets/glass/sounds/projection/activate_3.ogg differ diff --git a/glass/src/main/resources/assets/glass/sounds/projection/deactivate_1.ogg b/glass/src/main/resources/assets/glass/sounds/projection/deactivate_1.ogg new file mode 100644 index 0000000..fc8396a Binary files /dev/null and b/glass/src/main/resources/assets/glass/sounds/projection/deactivate_1.ogg differ diff --git a/glass/src/main/resources/assets/glass/sounds/projection/deactivate_2.ogg b/glass/src/main/resources/assets/glass/sounds/projection/deactivate_2.ogg new file mode 100644 index 0000000..9398a9d Binary files /dev/null and b/glass/src/main/resources/assets/glass/sounds/projection/deactivate_2.ogg differ diff --git a/glass/src/main/resources/assets/glass/sounds/projection/deactivate_3.ogg b/glass/src/main/resources/assets/glass/sounds/projection/deactivate_3.ogg new file mode 100644 index 0000000..32243f6 Binary files /dev/null and b/glass/src/main/resources/assets/glass/sounds/projection/deactivate_3.ogg differ diff --git a/glass/src/main/resources/assets/glass/textures/block/powerable_glass.png b/glass/src/main/resources/assets/glass/textures/block/powerable_glass.png new file mode 100644 index 0000000..4080528 Binary files /dev/null and b/glass/src/main/resources/assets/glass/textures/block/powerable_glass.png differ diff --git a/glass/src/main/resources/assets/glass/textures/block/powerable_glass_side.png b/glass/src/main/resources/assets/glass/textures/block/powerable_glass_side.png new file mode 100644 index 0000000..634a895 Binary files /dev/null and b/glass/src/main/resources/assets/glass/textures/block/powerable_glass_side.png differ diff --git a/glass/src/main/resources/assets/glass/textures/block/projector.png b/glass/src/main/resources/assets/glass/textures/block/projector.png new file mode 100644 index 0000000..5296a6a Binary files /dev/null and b/glass/src/main/resources/assets/glass/textures/block/projector.png differ diff --git a/glass/src/main/resources/assets/glass/textures/block/projector1.png b/glass/src/main/resources/assets/glass/textures/block/projector1.png new file mode 100644 index 0000000..9ebd461 Binary files /dev/null and b/glass/src/main/resources/assets/glass/textures/block/projector1.png differ diff --git a/glass/src/main/resources/assets/glass/textures/block/redstone_infused_sand.png b/glass/src/main/resources/assets/glass/textures/block/redstone_infused_sand.png new file mode 100644 index 0000000..464f542 Binary files /dev/null and b/glass/src/main/resources/assets/glass/textures/block/redstone_infused_sand.png differ diff --git a/glass/src/main/resources/assets/glass/textures/block/terminal.png b/glass/src/main/resources/assets/glass/textures/block/terminal.png new file mode 100644 index 0000000..d84e343 Binary files /dev/null and b/glass/src/main/resources/assets/glass/textures/block/terminal.png differ diff --git a/glass/src/main/resources/fabric.mod.json b/glass/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..4b5a9c0 --- /dev/null +++ b/glass/src/main/resources/fabric.mod.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "id": "glass", + "version": "${version}", + "name": "GLASS Remastered", + "description": "${mod_description}", + "authors": [ + "IMB11" + ], + "contributors": ["thepigcat (Artist)"], + "contact": { + "sources": "https://github.com/IMB11-Mods/JavaEditionMods", + "issues": "https://github.com/IMB11-Mods/JavaEditionMods/issues" + }, + "license": "All-Rights-Reserved", + "icon": "assets/glass/icon.png", + "accessWidener": "glass.accesswidener", + "environment": "*", + "entrypoints": { + "client": [ + "dev.imb11.fabric.GlassFabricClient" + ], + "main": [ + "dev.imb11.fabric.GlassFabric" + ], + "fabric-datagen": [ + "dev.imb11.datagen.GlassDataGenerator" + ] + }, + "mixins": [ + "glass.mixins.json" + ], + "depends": { + "fabricloader": ">=${target_fabricloader}", + "fabric-api": "*", + "minecraft": "${target_minecraft}", + "java": ">=25" + } +} diff --git a/glass/src/main/resources/glass.accesswidener b/glass/src/main/resources/glass.accesswidener new file mode 100644 index 0000000..fafb596 --- /dev/null +++ b/glass/src/main/resources/glass.accesswidener @@ -0,0 +1,3 @@ +accessWidener v2 official +accessible class net/minecraft/world/level/block/entity/BlockEntityType$BlockEntitySupplier +accessible method net/minecraft/world/level/block/entity/BlockEntityType (Lnet/minecraft/world/level/block/entity/BlockEntityType$BlockEntitySupplier;Ljava/util/Set;)V diff --git a/glass/src/main/resources/glass.mixins.json b/glass/src/main/resources/glass.mixins.json new file mode 100644 index 0000000..2774816 --- /dev/null +++ b/glass/src/main/resources/glass.mixins.json @@ -0,0 +1,32 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.imb11.mixins", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "ChunkMapAccessor", + "ChunkMapMixin", + "TrackedEntityAccessor", + "TrackedEntityMixin", + "ServerChunkCacheMixin" + ], + "client": [ + "BufferSourceAccessor", + "ClientChunkCacheMixin", + "ClientLevelMixin", + "ClientPacketListenerMixin", + "compat.SodiumWorldRendererMixin", + "GameRendererMixin", + "LevelRendererBufferAccessor", + "LevelRendererMixin", + "MinecraftMixin", + "RenderChunkRegionMixin", + "RenderRegionCacheMixin", + "TextureAtlasMixin", + "ViewAreaInvoker", + "ProjectionRendererAccessor" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/gradle/minecraft/26.1.2.versions.toml b/gradle/minecraft/26.1.2.versions.toml index 356925c..af81256 100644 --- a/gradle/minecraft/26.1.2.versions.toml +++ b/gradle/minecraft/26.1.2.versions.toml @@ -32,6 +32,8 @@ polytone-fabric = "3MAkNiv1" polytone-neoforge = "osavRdFq" iris-fabric = "sZbVsl2Q" iris-neoforge = "qE5Y7GrZ" +sodium-fabric = "mc26.1.2-0.9.2-fabric" +sodium-neoforge = "mc26.1.2-0.9.2-neoforge" [libraries] architectury-fabric = { module = "dev.architectury:architectury-fabric", version.ref = "architectury" } @@ -40,6 +42,8 @@ polytone-fabric = { module = "maven.modrinth:polytone", version.ref = "polytone- polytone-neoforge = { module = "maven.modrinth:polytone", version.ref = "polytone-neoforge" } iris-fabric = { module = "maven.modrinth:iris", version.ref = "iris-fabric" } iris-neoforge = { module = "maven.modrinth:iris", version.ref = "iris-neoforge" } +sodium-fabric = { module = "maven.modrinth:sodium", version.ref = "sodium-fabric" } +sodium-neoforge = { module = "maven.modrinth:sodium", version.ref = "sodium-neoforge" } accessories-fabric = { module = "io.wispforest:accessories-neoforge", version.ref = "accessories-fabric" } accessories-neoforge = { module = "io.wispforest:accessories-neoforge", version.ref = "accessories-neoforge" } cca-base = { module = "org.ladysnake.cardinal-components-api:cardinal-components-base", version.ref = "cca" } diff --git a/scripts/migrate-fog-issues.ps1 b/scripts/migrate-fog-issues.ps1 new file mode 100644 index 0000000..2f75d0b --- /dev/null +++ b/scripts/migrate-fog-issues.ps1 @@ -0,0 +1,547 @@ +param( + [switch]$Apply, + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string]$Source = 'IMB11/Fog', + [string]$Checkpoint = (Join-Path $PSScriptRoot '../.imports/fog-issue-migration.json'), + [ValidateRange(1, 8)] + [int]$Concurrency = 4, + [ValidateRange(750, 60000)] + [int]$WriteIntervalMs = 800 +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +$OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$Destination = 'IMB11-Mods/JavaEditionMods' +$ProjectLabel = 'project:fog' +$TypeLabels = [ordered]@{ + Bug = @('bug') + Feature = @('feature request', 'enhancement', 'feature') + Task = @('task') +} +$ExcludedLabels = @('bug', 'feature request', 'enhancement', 'feature', 'task', 'duplicate') +$RequestState = [hashtable]::Synchronized(@{ + NextWriteAt = [DateTime]::MinValue + CooldownUntil = [DateTime]::MinValue + Stopping = $false +}) + +function Wait-RequestSlot { + param([switch]$Write, [switch]$Cleanup) + + if ($Write -and -not $Apply) { throw 'GitHub changes require -Apply.' } + while ($true) { + [System.Threading.Monitor]::Enter($RequestState.SyncRoot) + try { + if ($RequestState.Stopping -and -not $Cleanup) { + throw [System.OperationCanceledException]::new('Migration stopped after a fatal API error.') + } + $now = [DateTime]::UtcNow + $availableAt = $RequestState.CooldownUntil + if ($Write -and $RequestState.NextWriteAt -gt $availableAt) { $availableAt = $RequestState.NextWriteAt } + $waitMs = ($availableAt - $now).TotalMilliseconds + if ($waitMs -le 0) { + if ($Write) { $RequestState.NextWriteAt = $now.AddMilliseconds($WriteIntervalMs) } + return + } + } finally { + [System.Threading.Monitor]::Exit($RequestState.SyncRoot) + } + Start-Sleep -Milliseconds ([int][Math]::Min(1000, [Math]::Ceiling($waitMs))) + } +} + +function New-GitHubRequestError { + param([string]$Response) + + $status = [regex]::Match($Response, '(?im)^HTTP/\S+\s+(?\d+)') + $statusCode = if ($status.Success) { [int]$status.Groups['code'].Value } else { 0 } + $bodyMatch = [regex]::Match($Response, '(?ms)^[ \t]*(?[\{\[].*)\z') + $body = $null + if ($bodyMatch.Success) { + try { $body = $bodyMatch.Groups['json'].Value | ConvertFrom-Json -AsHashtable -Depth 100 } catch { } + } + $messages = @() + if ($body -is [System.Collections.IDictionary]) { + if ($body.errors) { $messages = @($body.errors | ForEach-Object { if ($_.message) { $_.message } else { [string]$_ } }) } + if (-not $messages.Count -and $body.message) { $messages = @($body.message) } + } + if (-not $messages.Count) { + $messages = @([regex]::Matches($Response, '(?im)^gh:\s*(.+)$') | ForEach-Object { $_.Groups[1].Value.Trim() }) + } + if (-not $messages.Count) { + $messages = if ($statusCode) { @("HTTP $statusCode") } else { @(($Response.Trim() -split '\r?\n')[-1]) } + } + $errorTypes = @($body.errors | ForEach-Object { $_.type }) + $exception = [System.InvalidOperationException]::new("GitHub: $($messages -join '; ')") + $exception.Data['StopMigration'] = ( + $statusCode -in @(401, 403, 429) -or + @($errorTypes | Where-Object { $_ -in @('FORBIDDEN', 'UNAUTHORIZED', 'RATE_LIMITED') }).Count -gt 0 + ) + return $exception +} + +function Invoke-Gh { + param([string[]]$Arguments, [string]$Body, [switch]$Write, [switch]$Cleanup) + + for ($attempt = 0; $attempt -lt 4; $attempt++) { + Wait-RequestSlot -Write:$Write -Cleanup:$Cleanup + if ($Body) { + $result = $Body | & gh @Arguments 2>&1 + } else { + $result = & gh @Arguments 2>&1 + } + $exitCode = $LASTEXITCODE + $text = $result -join [Environment]::NewLine + if ($exitCode -eq 0) { return $text } + if ($attempt -eq 3 -or $text -notmatch '(?i)rate.limit|abuse detection|HTTP(?:/[0-9.]+)?\s+429\b') { + throw (New-GitHubRequestError -Response $text) + } + $waitSeconds = 60 * [Math]::Pow(2, $attempt) + $retryAfter = [regex]::Match($text, '(?im)^retry-after:\s*(\d+)') + if ($retryAfter.Success) { $waitSeconds = [Math]::Max($waitSeconds, [double]$retryAfter.Groups[1].Value) } + $remaining = [regex]::Match($text, '(?im)^x-ratelimit-remaining:\s*0\s*$') + $reset = [regex]::Match($text, '(?im)^x-ratelimit-reset:\s*(\d+)') + if ($remaining.Success -and $reset.Success) { + $resetAt = [DateTimeOffset]::FromUnixTimeSeconds([long]$reset.Groups[1].Value).UtcDateTime + $waitSeconds = [Math]::Max($waitSeconds, ($resetAt - [DateTime]::UtcNow).TotalSeconds + 1) + } + [System.Threading.Monitor]::Enter($RequestState.SyncRoot) + try { + $until = [DateTime]::UtcNow.AddSeconds($waitSeconds) + if ($until -gt $RequestState.CooldownUntil) { $RequestState.CooldownUntil = $until } + } finally { + [System.Threading.Monitor]::Exit($RequestState.SyncRoot) + } + Write-Host "GitHub rate limit: pausing all workers for at least $([Math]::Ceiling($waitSeconds)) seconds." + } +} + +function Invoke-GitHubApi { + param([string]$Endpoint, [string]$Method = 'GET', [hashtable]$Payload, [switch]$Cleanup) + + $arguments = @( + 'api', '--hostname', 'github.com', '--include', '--method', $Method, $Endpoint, + '-H', 'Accept: application/vnd.github+json', + '-H', 'X-GitHub-Api-Version: 2022-11-28' + ) + $body = '' + if ($null -ne $Payload) { + $arguments += @('--input', '-') + $body = ConvertTo-Json -InputObject $Payload -Depth 100 -Compress + } + $response = Invoke-Gh -Arguments $arguments -Body $body -Write:($Method -ne 'GET') -Cleanup:$Cleanup + if ($response -match '(?im)^HTTP/\S+\s+204\b') { return $null } + $bodyMatch = [regex]::Match($response, '(?ms)^[ \t]*(?[\{\[].*)\z') + if (-not $bodyMatch.Success) { throw "GitHub response has no JSON body: $Endpoint" } + $parsed = $bodyMatch.Groups['json'].Value | ConvertFrom-Json -AsHashtable -Depth 100 -NoEnumerate + if ($parsed -is [System.Collections.IDictionary] -and $parsed.errors) { + throw (New-GitHubRequestError -Response $response) + } + return ,$parsed +} + +function Get-ApiPages { + param([string]$Endpoint) + + $separator = if ($Endpoint.Contains('?')) { '&' } else { '?' } + for ($page = 1; ; $page++) { + $batch = Invoke-GitHubApi "$Endpoint${separator}per_page=100&page=$page" + foreach ($item in $batch) { $item } + if ($batch.Count -lt 100) { break } + } +} + +function Get-DestinationIssue { + param([string]$Url, [switch]$Cleanup) + + $pattern = '^https://github\.com/' + [regex]::Escape($Destination) + '/issues/(?[1-9]\d*)/?$' + $match = [regex]::Match($Url.Trim(), $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if (-not $match.Success) { throw "Invalid destination issue URL: $Url" } + $number = $match.Groups['number'].Value + return Invoke-GitHubApi "repos/$Destination/issues/$number" -Cleanup:$Cleanup +} + +function Move-Issue { + param([string]$IssueId, [string]$RepositoryId) + + $query = @' +mutation TransferIssue($issueId: ID!, $repositoryId: ID!) { + transferIssue(input: {issueId: $issueId, repositoryId: $repositoryId, createLabelsIfMissing: false}) { + issue { + number + url + repository { nameWithOwner } + labels(first: 100) { + nodes { name } + pageInfo { hasNextPage } + } + } + } +} +'@ + $response = Invoke-GitHubApi 'graphql' 'POST' @{ + query = $query + variables = @{ issueId = $IssueId; repositoryId = $RepositoryId } + } + $moved = $response.data.transferIssue.issue + if (-not $moved.url -or -not $moved.number) { throw 'Transfer response did not include the destination issue.' } + return $moved +} + +function Get-IssueSnapshot { + param([System.Collections.IDictionary]$Issue) + + return [ordered]@{ + Number = $Issue.number + Url = $Issue.html_url + Title = $Issue.title + State = $Issue.state + StateReason = $Issue.state_reason + Type = $Issue.type.name + Labels = @($Issue.labels | Sort-Object { $_.name } | ForEach-Object { + [ordered]@{ name = $_.name; color = $_.color; description = $_.description } + }) + Milestone = if ($Issue.milestone) { + [ordered]@{ title = $Issue.milestone.title; due_on = $Issue.milestone.due_on } + } else { $null } + } +} + +function Get-IssueType { + param([System.Collections.IDictionary]$Issue) + + $names = @($Issue.Labels | ForEach-Object { $_.name }) + foreach ($type in $TypeLabels.Keys) { + if ($TypeLabels[$type] | Where-Object { $names -contains $_ }) { return $type } + } + return $Issue.Type +} + +function Get-FinalLabelNames { + param([string[]]$Names) + + @($Names) + $ProjectLabel | Where-Object { $ExcludedLabels -notcontains $_ } | Sort-Object -Unique +} + +function Restore-IssueLock { + param([System.Collections.IDictionary]$Issue, [System.Collections.IDictionary]$LockState, [switch]$Cleanup) + + $repository = if ($Issue.repository_url -eq "https://api.github.com/repos/$Source") { + $Source + } elseif ($Issue.repository_url -eq "https://api.github.com/repos/$Destination") { + $Destination + } else { throw 'Refusing to restore a lock in an unexpected repository.' } + $payload = @{} + if ($LockState.Reason) { $payload.lock_reason = $LockState.Reason } + $null = Invoke-GitHubApi "repos/$repository/issues/$($Issue.number)/lock" 'PUT' $payload -Cleanup:$Cleanup +} + +function Invoke-IssueMigration { + param([System.Collections.IDictionary]$Work) + + $original = $Work.Original + $current = $Work.Current + $timer = [System.Diagnostics.Stopwatch]::StartNew() + $restorePending = [bool]$Work.LockState.Locked + $moved = $null + Write-Host "Processing source #$($original.Number)..." + try { + if ($current.repository_url -eq "https://api.github.com/repos/$Source") { + if ($current.locked) { + Write-Host "Temporarily unlocking source #$($original.Number) for transfer." + $null = Invoke-GitHubApi "repos/$Source/issues/$($original.Number)/lock" 'DELETE' + } + $moved = Move-Issue -IssueId $current.node_id -RepositoryId $DestinationId + [pscustomobject]@{ Kind = 'Transferred'; Key = $Work.Key; Url = $moved.url } + $current = if ($moved.labels.pageInfo.hasNextPage) { + Get-DestinationIssue $moved.url + } else { + @{ + number = $moved.number + html_url = $moved.url + repository_url = "https://api.github.com/repos/$($moved.repository.nameWithOwner)" + labels = @($moved.labels.nodes) + } + } + } + if ($current.repository_url -ne "https://api.github.com/repos/$Destination") { + throw "Source #$($original.Number) did not resolve to the destination repository." + } + if ($Work.DestinationUrl -ne $current.html_url -and $Work.Current.repository_url -ne "https://api.github.com/repos/$Source") { + [pscustomobject]@{ Kind = 'Transferred'; Key = $Work.Key; Url = $current.html_url } + } + if ($restorePending) { + if (-not $current.locked -or $current.active_lock_reason -ne $Work.LockState.Reason) { + Restore-IssueLock -Issue $current -LockState $Work.LockState + } + $restorePending = $false + } + $type = Get-IssueType $original + $candidateLabels = @($original.Labels | ForEach-Object { $_.name }) + @($current.labels | ForEach-Object { $_.name }) + $expectedLabels = @( + Get-FinalLabelNames -Names $candidateLabels | ForEach-Object { + if ($LabelMap.ContainsKey($_)) { $LabelMap[$_].name } else { $_ } + } + ) + $payload = @{ labels = $expectedLabels } + if ($type) { $payload.type = $IssueTypes[$type] } + $updated = Invoke-GitHubApi "repos/$Destination/issues/$($current.number)" 'PATCH' $payload + $actualLabels = @($updated.labels | ForEach-Object { $_.name }) + if (@($expectedLabels | Where-Object { $actualLabels -notcontains $_ }).Count -or + @($actualLabels | Where-Object { $expectedLabels -notcontains $_ }).Count) { + throw "Labels were not applied to $($updated.html_url)." + } + if ($type -and $updated.type.name -ne $IssueTypes[$type]) { throw "Issue type was not applied to $($updated.html_url)." } + if ($updated.state -ne $original.State -or $updated.state_reason -ne $original.StateReason) { + throw "State changed on $($updated.html_url); inspect before resuming." + } + if ($Work.LockState.Locked -and (-not $updated.locked -or $updated.active_lock_reason -ne $Work.LockState.Reason)) { + $restorePending = $true + throw "Original conversation lock was not restored on $($updated.html_url)." + } + } catch { + $migrationFailure = $_ + if ($restorePending) { + try { + $latest = if ($moved.url) { + Get-DestinationIssue $moved.url -Cleanup + } elseif ($current.repository_url -eq "https://api.github.com/repos/$Destination") { + Get-DestinationIssue $current.html_url -Cleanup + } else { + Invoke-GitHubApi "repos/$Source/issues/$($original.Number)" -Cleanup + } + if ($latest.repository_url -eq "https://api.github.com/repos/$Destination") { + [pscustomobject]@{ Kind = 'Transferred'; Key = $Work.Key; Url = $latest.html_url } + } + Restore-IssueLock -Issue $latest -LockState $Work.LockState -Cleanup + } catch { + $exception = [System.InvalidOperationException]::new( + "$($migrationFailure.Exception.Message) Lock restoration also failed: $($_.Exception.Message). Resume with the same checkpoint to restore it." + ) + $exception.Data['StopMigration'] = $true + throw $exception + } + } + throw $migrationFailure + } + [pscustomobject]@{ + Kind = 'Completed'; Key = $Work.Key; Url = $updated.html_url + Seconds = [Math]::Round($timer.Elapsed.TotalSeconds, 1) + } +} + +function Save-Checkpoint { + $directory = Split-Path -Parent $Checkpoint + [void][System.IO.Directory]::CreateDirectory($directory) + $json = ConvertTo-Json -InputObject $script:Progress -Depth 100 + [System.IO.File]::WriteAllText("$Checkpoint.tmp", $json, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::Move("$Checkpoint.tmp", $Checkpoint, $true) +} + +try { + if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw 'Run this script with PowerShell 7.3 or newer (pwsh).' } + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw 'Install GitHub CLI and authenticate with gh auth login.' + } + $Checkpoint = [System.IO.Path]::GetFullPath($Checkpoint) + $sourceRepo = Invoke-GitHubApi "repos/$Source" + $destinationRepo = Invoke-GitHubApi "repos/$Destination" + foreach ($repo in @($sourceRepo, $destinationRepo)) { + if ($repo.archived -or -not $repo.has_issues -or -not $repo.permissions.push) { + throw "$($repo.full_name) must have issues enabled, be unarchived, and grant you write access." + } + } + $Source = $sourceRepo.full_name + $Destination = $destinationRepo.full_name + if ($sourceRepo.owner.id -ne $destinationRepo.owner.id) { + throw "GitHub issue transfers require the same owner. Transfer the $Source repository to $($destinationRepo.owner.login) first, then rerun this script. Repository redirects are followed automatically." + } + if ($sourceRepo.private -and -not $destinationRepo.private) { + throw 'GitHub cannot transfer private issues into a public repository.' + } + $types = @{} + foreach ($type in (Invoke-GitHubApi "orgs/$($destinationRepo.owner.login)/issue-types")) { + if ($type.is_enabled) { $types[$type.name] = $type.name } + } + $identity = @{ + Version = 1; Source = $Source; Destination = $Destination + SourceId = $sourceRepo.id; DestinationId = $destinationRepo.id + } + if (Test-Path -LiteralPath $Checkpoint) { + $script:Progress = Get-Content -LiteralPath $Checkpoint -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + foreach ($key in $identity.Keys) { + if ($script:Progress[$key] -ne $identity[$key]) { throw 'Checkpoint belongs to a different migration.' } + } + if ($script:Progress.Issues -isnot [System.Collections.IDictionary]) { throw 'Invalid checkpoint issue list.' } + } else { + $script:Progress = $identity + $script:Progress.Issues = @{} + } + $sourceIssues = @(Get-ApiPages "repos/$Source/issues?state=all&sort=created&direction=asc") + $sourceByNumber = @{} + foreach ($issue in $sourceIssues) { + if ($issue.pull_request) { continue } + $key = [string]$issue.number + $sourceByNumber[$key] = $issue + if (-not $script:Progress.Issues.Contains($key)) { + $script:Progress.Issues[$key] = @{ Original = Get-IssueSnapshot $issue; Completed = $false } + } + } + $pending = @($script:Progress.Issues.Values | Where-Object { -not $_.Completed } | Sort-Object { $_.Original.Number }) + $labels = @{} + foreach ($label in (Get-ApiPages "repos/$Destination/labels")) { $labels[$label.name] = $label } + $requiredLabels = @{} + $requiredLabels[$ProjectLabel] = @{ name = $ProjectLabel; color = '1D76DB'; description = 'Issues for Fog' } + foreach ($entry in $pending) { + $original = $entry.Original + $type = Get-IssueType $original + if ($type -and -not $types.ContainsKey($type)) { throw "Enable the '$type' issue type before migrating." } + $retainedNames = @(Get-FinalLabelNames -Names @($original.Labels | ForEach-Object { $_.name })) + foreach ($label in $original.Labels) { + if ($retainedNames -notcontains $label.name) { continue } + if (-not $requiredLabels.ContainsKey($label.name)) { + $requiredLabels[$label.name] = @{ + name = $label.name; color = $label.color; description = [string]$label.description + } + } + } + } + $mode = if ($Apply) { 'APPLY' } else { 'PREVIEW' } + Write-Host "${mode}: $Source -> $Destination ($($pending.Count) pending issues)" + Write-Host 'Omit type labels and duplicate; keep other labels and add project:fog.' + Write-Host 'Bug takes precedence over Feature, then Task.' + foreach ($entry in $pending) { + $original = $entry.Original + $type = Get-IssueType $original + $names = @(Get-FinalLabelNames -Names @($original.Labels | ForEach-Object { $_.name })) + if (-not $type) { $type = '(untyped)' } + Write-Host "#$($original.Number) [$($original.State)] type=$type labels=$($names -join ', ')" + } + $missingLabels = @($requiredLabels.Values | Where-Object { -not $labels.ContainsKey($_.name) }) + foreach ($label in $missingLabels) { Write-Host "Create destination label: $($label.name)" } + if (-not $pending.Count) { Write-Host 'No pending issues.'; return } + if (-not $Apply) { + Write-Host 'Preview only. No GitHub changes or checkpoint writes. Add -Apply to migrate.' + return + } + Write-Host 'Fetching destination issues and preparing all migration requests...' + $destinationByUrl = @{} + foreach ($issue in (Get-ApiPages "repos/$Destination/issues?state=all&sort=created&direction=asc")) { + if (-not $issue.pull_request) { $destinationByUrl[$issue.html_url] = $issue } + } + $workItems = [System.Collections.Generic.List[object]]::new() + foreach ($entry in $pending) { + $original = $entry.Original + $key = [string]$original.Number + $current = if ($entry.DestinationUrl) { + if ($destinationByUrl.ContainsKey($entry.DestinationUrl)) { + $destinationByUrl[$entry.DestinationUrl] + } else { + Get-DestinationIssue $entry.DestinationUrl + } + } elseif ($sourceByNumber.ContainsKey($key)) { + $sourceByNumber[$key] + } else { + Invoke-GitHubApi "repos/$Source/issues/$key" + } + if ($current.pull_request) { throw "Source #$key unexpectedly resolves to a pull request." } + if ($current.repository_url -eq "https://api.github.com/repos/$Source") { + $fresh = ConvertTo-Json -InputObject (Get-IssueSnapshot $current) -Depth 100 -Compress + $saved = ConvertTo-Json -InputObject $original -Depth 100 -Compress + if ($fresh -cne $saved) { + throw "Source #$key changed. Review and remove only this untransferred checkpoint entry, then rerun." + } + } elseif ($current.repository_url -ne "https://api.github.com/repos/$Destination") { + throw "Source #$key belongs to an unexpected repository." + } + if (-not $entry.LockState -or $current.locked) { + $entry.LockState = @{ Locked = [bool]$current.locked; Reason = $current.active_lock_reason } + } + $workItems.Add(@{ + Key = $key; Original = $original; Current = $current; DestinationUrl = $entry.DestinationUrl + LockState = $entry.LockState + }) + } + Save-Checkpoint + foreach ($label in $missingLabels) { + $created = Invoke-GitHubApi "repos/$Destination/labels" 'POST' $label + $labels[$created.name] = $created + } + $workerFunctions = [ordered]@{} + foreach ($name in @( + 'Wait-RequestSlot', 'New-GitHubRequestError', 'Invoke-Gh', 'Invoke-GitHubApi', 'Get-DestinationIssue', + 'Move-Issue', 'Get-IssueType', 'Get-FinalLabelNames', 'Restore-IssueLock', 'Invoke-IssueMigration' + )) { + $workerFunctions[$name] = (Get-Item "Function:$name").Definition + } + $destinationId = $destinationRepo.node_id + $failures = [System.Collections.Generic.List[string]]::new() + $completedThisRun = 0 + Write-Host "Running $Concurrency parallel workers with a shared ${WriteIntervalMs}ms write interval." + $lockedCount = @($workItems | Where-Object { $_.LockState.Locked }).Count + if ($lockedCount) { Write-Host "$lockedCount locked issue(s) will have their original lock restored after transfer." } + $workItems | ForEach-Object -Parallel { + $work = $_ + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $false + $OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $Apply = [bool]$using:Apply + $Source = $using:Source + $Destination = $using:Destination + $DestinationId = $using:destinationId + $ProjectLabel = $using:ProjectLabel + $TypeLabels = $using:TypeLabels + $ExcludedLabels = $using:ExcludedLabels + $LabelMap = $using:labels + $IssueTypes = $using:types + $RequestState = $using:RequestState + $WriteIntervalMs = $using:WriteIntervalMs + $definitions = $using:workerFunctions + if ($RequestState.Stopping) { return } + try { + foreach ($definition in $definitions.GetEnumerator()) { + Set-Item -Path "Function:$($definition.Key)" -Value ([scriptblock]::Create($definition.Value)) + } + Invoke-IssueMigration -Work $work + } catch { + if ($_.Exception.Data['StopMigration']) { $RequestState.Stopping = $true } + if ($_.Exception -isnot [System.OperationCanceledException]) { + [pscustomobject]@{ Kind = 'Failed'; Key = $work.Key; Message = $_.Exception.Message } + } + } + } -ThrottleLimit $Concurrency | ForEach-Object { + $migrationEvent = $_ + $entry = $script:Progress.Issues[$migrationEvent.Key] + try { + switch ($migrationEvent.Kind) { + 'Transferred' { + $entry.DestinationUrl = $migrationEvent.Url + Save-Checkpoint + } + 'Completed' { + $entry.DestinationUrl = $migrationEvent.Url + $entry.Completed = $true + Save-Checkpoint + $completedThisRun++ + Write-Host "[$completedThisRun/$($pending.Count)] $($entry.Original.Url) -> $($migrationEvent.Url) ($($migrationEvent.Seconds)s)" + } + 'Failed' { + $failures.Add("Source #$($migrationEvent.Key): $($migrationEvent.Message)") + Write-Host $failures[$failures.Count - 1] -ForegroundColor Red + } + } + } catch { + $RequestState.Stopping = $true + throw + } + } + if ($failures.Count) { throw "$($failures.Count) worker(s) stopped. $($failures[0])" } + $remaining = @(Get-ApiPages "repos/$Source/issues?state=all" | Where-Object { -not $_.pull_request }) + if ($remaining.Count) { throw "$($remaining.Count) issues remain. Rerun with the same checkpoint to include them." } + Write-Host "Migration complete. Progress and old/new URLs: $Checkpoint" +} catch { + Write-Host "Stopped: $($_.Exception.Message)" -ForegroundColor Red + Write-Host 'If -Apply was used, keep the checkpoint and rerun with it after resolving the error.' + exit 1 +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a093569..cb796c3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,7 +21,8 @@ val modTargets = mapOf( "fog" to listOf("26.1.2", "26.2", "26.3"), "mru" to listOf("26.1.2", "26.2", "26.3"), "sounds" to listOf("26.1.2", "26.3"), - "shields" to listOf("26.3") + "shields" to listOf("26.3"), + "glass" to listOf("26.1.2") ) dependencyResolutionManagement {