diff --git a/AGENTS.md b/AGENTS.md index b51a43a..01bc45c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## 当前兼容分支例外(2026-09-30,Codex) -本 worktree 为用户明确要求的 `compat/mtr-4.0-mc-1.20` 分支:**Forge/Fabric MC 1.20.1 / 1.20.4 + MTR 4.0.5,mod/pack/metadata 均保持 v1.5.2**。用户于2026-10-22明确要求移植最新功能后测试所有平台。禁止将这里的 Forge 代码覆盖回 main。下文的 NeoForge 1.21.1 平台约束适用于 main;本分支遵循上面的兼容目标。构建 Gradle 用 JDK 21,产物为 Java 17;`gradlew build` 与 `fabric/gradlew -p fabric build` 均运行共用测试;加 `-Pminecraft_version=1.20.4` 构建独立 1.20.4 目标。权威状态见 STATE.md,构建/功能说明以本分支 README 为准。 +本 worktree 为用户明确要求的 `compat/mtr-4.0-mc-1.20` 分支:**Forge/Fabric MC 1.20.1 / 1.20.4 + MTR 4.0.5,当前开发 mod/pack/metadata 为用户指定 v1.5.3(已发布稳定版仍1.5.2)**。用户于2026-10-22明确要求移植最新功能后测试所有平台。禁止将这里的 Forge 代码覆盖回 main。下文的 NeoForge 1.21.1 平台约束适用于 main;本分支遵循上面的兼容目标。构建 Gradle 用 JDK 21,产物为 Java 17;`gradlew build` 与 `fabric/gradlew -p fabric build` 均运行共用测试;加 `-Pminecraft_version=1.20.4` 构建独立 1.20.4 目标。权威状态见 STATE.md,构建/功能说明以本分支 README 为准。 ## 0. 三条铁律(违反 = 事故重演) @@ -14,7 +14,7 @@ main 分支上不存在 Forge 1.20.1 代码(旧代码只存在于 git 历史 `77121f5` 之前)。 如果你发现工作区/代码"看起来像 Forge 1.20.1"或与你记忆中的项目不符——**是你的认知过时了**, 请先读 `docs/agents/STATE.md` 和 `walkthrough.md`,而不是"修复"它。 -2. **版本号由用户决定**:当前固定 `mod_version=1.5.2`,仅用户明确要求时才更改;禁止自动递增。 +2. **版本号由用户决定**:用户指定1.5.3并授权实施,本兼容移植的开发版本为 `mod_version=1.5.3`;禁止自动递增。 版本号会自动展开进 `META-INF/neoforge.mods.toml` 和 `pack.mcmeta` 的 description (`pack.mcmeta` 用 `${version}` 占位符,不要手写死版本号)。 3. **禁止破坏性 git 操作**:不得执行 `git reset --hard`、`git checkout -- .`、`git push --force`、 diff --git a/README.md b/README.md index 4cb00b1..094e4d7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The server component is optional. This branch provides separate JARs for **MC 1. ## Commands and configuration +**1.5.3 development:** station arrival boards, two PIDS themes, owned view/dimmed spans and the optional navigation entry are documented in the [public API](docs/api/overlay-api-v1.md). The published stable release remains 1.5.2. + Commands are registered on the **client**, so they are available even when the server does not run this mod. | Command | Purpose | @@ -63,6 +65,7 @@ Commands are registered on the **client**, so they are available even when the s | `/mtrmap config routeLines ` | Show or hide route ribbons on both maps. | | `/mtrmap config trackLines ` | Show or hide physical rails on both maps. | | `/mtrmap config trainCars ` | Show or hide carriages on both maps. | +| `/mtrmap config pidsStyle british\|european` | 1.5.3 development: station PIDS style; British is the default. Query with no argument. | Forge stores settings in `config/mtrmap.toml` and copies an existing `mtrsurveyor.toml` on first launch when the new file is absent. Fabric uses `config/mtrmap.properties` with its own defaults. The two formats are not automatically interchangeable. Important options include `networkSync.enabled` (default `true`), `networkSync.refreshIntervalSeconds` (default `300`), and the station/platform/depot visibility switches. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7e49373..d6dc797 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,79 @@ +# v1.5.3 (release preparation) + +Compared with v1.5.2, this release adds native station arrival boards, two PIDS themes and a public API for third-party navigation views. + +## Added + +- **Pinned station information and native arrivals.** Click a station to keep its original station/platform/route information open. The card starts collapsed; **Expand** shows the next available services by destination, line and platform, using the same MTR/TSC arrival-data interface used for PIDS. Closing or collapsing the board releases its subscription. Missing predictions are displayed explicitly rather than as zero-minute arrivals. +- **British and European/airport PIDS styles.** British black/amber is the default. Use `/mtrmap config pidsStyle british` or `/mtrmap config pidsStyle european` to switch immediately and save the choice. Omit the style argument to query the current setting. +- **A unified navigation/view API.** Third-party mods can read native station/route/stop/path identifiers, resolve route sections, open owned view sessions and subscribe to station boards. They can highlight upcoming sections while independently dimming completed sections and unused lines, including different sections of the same physical rail. The **Navigate** entry supports registered providers and a chooser when several are available. It is disabled when no provider is installed; this release does not include an MTR-Navigation/howtogo-specific adapter. + +## Optimized and fixed + +- Reused native MTR/TSC arrival predictions without adding a client-side transit simulation. Station interests share server collection/encoding, with bounded leases, queues, paced chunks and fair transmission across players and interests. Clients decode in the background and reject stale snapshots. +- Preserved the classic station card and Xaero ROUTES/TRACKS controls. Improved narrow-window summaries, long-text hover details, aligned PIDS columns and ETA readability. Panel scrolling and clicks are intercepted before map zoom/pan actions. +- Fixed rail layers and the player arrow appearing over station panels. Retained separate Forge 47/49 input/network adapters and JourneyMap 5/6 rendering bridges. +- Navigation dimming applies consistently to route colours, high-speed white blocks, feathered edges and physical backing. Overlapping upcoming sections remain bright even when an earlier navigation leg uses the same track. +- All six builds include these features. Whole-map live trains, native XYZ storage and pure 2D rendering remain available; vehicle-hover ETA/next-stop distance is still deferred. + +## 中文更新说明 + +相较 v1.5.2,新增原生站点到站板、英式/欧式两套 PIDS,以及供第三方导航模组接入的统一地图视图 API。 + +### 新增功能 + +- **站点点击固定与原生到站预测**:保留原站点、站台和线路信息,默认折叠;点击 **Expand** 后按目的地、线路和站台显示下一班可用服务。直接使用 MTR/TSC 给 PIDS 的到站数据接口;无预测明确显示缺失,折叠或关闭后释放订阅。 +- **两种 PIDS 样式**:默认英式黑底琥珀字,可用 `/mtrmap config pidsStyle british|european` 即时切换并保存;不带样式参数可查询当前设置。 +- **统一导航/显示接口**:第三方可读取原生站点、线路、停站序号和路径目录,解析区段并控制独立视图。待乘区段保持明亮,已走区段和未用线路可分别变暗,同一物理轨道也能按区段控制。预留 **Navigate** 入口及多 provider 选择;没有 provider 时禁用。本版未内置特定导航模组的适配器。 + +### 优化与修复 + +- 服务端共享原生预测采集和编码,增加租约、容量限制、后台队列、分片节流与玩家/订阅轮转;客户端后台解码并拒绝旧快照,不维护第二套行车模拟。 +- 恢复旧式站点信息框与 Xaero ROUTES/TRACKS 控件;改善小窗口摘要、长文本悬停、表格列对齐和 ETA 显示,防止面板操作误触地图缩放/拖动。 +- 修复轨道和玩家箭头覆盖站点面板的问题,保留 Forge 47/49 和 JourneyMap 5/6 的独立兼容处理。 +- 导航 dimmed 同时作用于线路色、高铁白块、边缘及物理底衬;后续乘车区段重复使用同一轨道时保持明亮。 +- 六个平台同步提供本次功能;继续保留全图列车、真实 XYZ 和纯 2D 绘制。列车悬停 ETA/下一站距离仍暂缓。 + +## Choose the matching file / 下载环境 + +Install **one** mod JAR matching the exact Minecraft version, MTR version and loader. Native station arrivals and whole-map trains require matching client/server Overlay versions; client-only installations cannot provide the server snapshot service. + +按游戏版本、MTR 版本和加载器选择**一个** JAR。原生站点预测及全图列车需客户端和服务端安装匹配版本;仅客户端安装无法提供服务端快照服务。 + +| File | Minecraft | Loader | MTR | Java | +| --- | --- | --- | --- | --- | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.21.1-MTR4.1.0-beta.2-neoforge.jar` | 1.21.1 | NeoForge 21.1.x | 4.1.0-beta.2 | 21 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.21.1-MTR4.1.0-beta.2-fabric.jar` | 1.21.1 | Fabric | 4.1.0-beta.2 | 21 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.1-MTR4.0.5-forge.jar` | 1.20.1 | Forge 47.x | 4.0.5 | 17 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.1-MTR4.0.5-fabric.jar` | 1.20.1 | Fabric | 4.0.5 | 17 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.4-MTR4.0.5-forge.jar` | 1.20.4 | Forge 49.x | 4.0.5 | 17 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.4-MTR4.0.5-fabric.jar` | 1.20.4 | Fabric | 4.0.5 | 17 | + +## Validation and known limits / 验证与已知限制 + +Six-target builds, JAR checks, shared unit tests and public Java 17 API consumer checks passed. Development-runtime PIDS checks covered Xaero and JourneyMap on all six targets. This is distinct from a production-JAR/large-modpack acceptance claim; see the manual candidate record for the exact release-binary validation performed. + +**Forge 1.20.4 singleplayer quick-play failed during testing:** Xaero Minimap/WorldMap read a null `serverData` during player join and disconnected before the Overlay snapshot was applied. The root cause remains unconfirmed. The same target passed both-map PIDS checks when connected to a separate local server. Singleplayer quick-play is **not verified as working** in that tested combination. + +六目标构建、产物检查、共享单测和 Java17 API 消费者检查通过;六平台两地图均完成开发运行环境中的基础 PIDS 回归。此结论不等同 production JAR/大型整合包验收,具体发布二进制的验证范围以候选验收记录为准。 + +**Forge 1.20.4 单人 quick-play 存在已知启动问题**:测试时 Xaero 在玩家加入阶段读取空 `serverData`,于 Overlay 快照应用前断开连接,根因尚未确认。该目标连接独立本地服务器后的双地图 PIDS 测试通过;不能据此声称单人启动已通过。 + +Real third-party navigation-provider integration, large-network/multi-viewer performance and cross-machine play have not been validated. No FPS/bandwidth benchmark is claimed. 原生预测覆盖不足会明确显示缺失,不应理解为没有列车或零 ETA;真实第三方导航联动、大网/多观看者性能和跨机器联机仍未实测。 + +[Detailed validation / 完整验证记录](https://github.com/teamCreating/MTR-Map-Overlay/blob/v1.5.3/docs/validation/pids-and-dimmed-1.5.3.md) · [Public API / 公共接口](https://github.com/teamCreating/MTR-Map-Overlay/blob/v1.5.3/docs/api/overlay-api-v1.md) + +## Changes from the previous release / 与上一次 release 的 diff + +[Full changelog: v1.5.2 → v1.5.3](https://github.com/teamCreating/MTR-Map-Overlay/compare/v1.5.2...v1.5.3) + +[Minecraft 1.20 compatibility diff: v1.5.2-mc1.20 → v1.5.3-mc1.20](https://github.com/teamCreating/MTR-Map-Overlay/compare/v1.5.2-mc1.20...v1.5.3-mc1.20) + +The refreshed 1.5.2 namespace binaries were built from main `959b7e4da470bf8a1315877a972189cce7c8e776` / compat `54a0acd`; the original release tags were retained. [Diff from the actual refreshed main source / 与1.5.2更新附件实际源码比较](https://github.com/teamCreating/MTR-Map-Overlay/compare/959b7e4da470bf8a1315877a972189cce7c8e776...v1.5.3). + + +--- + # v1.5.2 (2026-10-22) Compared with v1.5.1, this release adds whole-map live trains and improves rail curves, shared-route joins and map zoom behaviour across all six supported builds. diff --git a/api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java b/api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java new file mode 100644 index 0000000..2c59af1 --- /dev/null +++ b/api/src/main/java/io/github/teamcreating/mtrmap/api/MtrMapApi.java @@ -0,0 +1,130 @@ +package io.github.teamcreating.mtrmap.api; + +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; + +/** Optional, client-thread API. Compile against the API JAR; do not shade it. */ +public final class MtrMapApi { + private static Client provider; + private MtrMapApi() {} + public static Optional findClient() { return Optional.ofNullable(provider); } + /** Runtime bootstrap; consumers must not replace the provider. */ + public static void install(Client client) { + if (provider != null && provider != client) throw new IllegalStateException("Provider already installed"); + provider = Objects.requireNonNull(client); + } + public enum Status { LOADING, READY, LIMITED, STALE, UNSUPPORTED, NOT_FOUND, ERROR, INVALID, AMBIGUOUS, UNAVAILABLE, CLOSED } + public record WorldRef(UUID serverEpoch, String dimensionId) { + public WorldRef { Objects.requireNonNull(serverEpoch); Objects.requireNonNull(dimensionId); } + } + public record Point(double x, double y, double z) {} + public record Station(String id, String name, Point position, List platformIds) { + public Station { platformIds = List.copyOf(platformIds); } + } + public record Platform(String id, String stationId, String name, Point position) {} + public record Stop(int ordinal, String platformId, String stationId, String destination) {} + /** Fractions use canonical rail direction and native Core distance, including slopes. */ + public record RailSpan(String railId, double from, double to) { + public RailSpan { + Objects.requireNonNull(railId); + if (!Double.isFinite(from) || !Double.isFinite(to) || from < 0 || from > 1 || to < 0 || to > 1 || from == to) + throw new IllegalArgumentException("Rail fractions must be distinct and within [0,1]"); + } + } + public record Leg(int fromStop, int toStop, List rails) { + public Leg { rails = List.copyOf(rails); } + } + public record PathVariant(String id, List legs) { + public PathVariant { legs = List.copyOf(legs); } + } + public record Route(String id, String name, String number, int color, String type, List stops, List paths) { + public Route { stops = List.copyOf(stops); paths = List.copyOf(paths); } + } + public record Catalog(WorldRef world, long revision, long baseStaticRevision, Status status, + List stations, List platforms, List routes) { + public Catalog { stations = List.copyOf(stations); platforms = List.copyOf(platforms); routes = List.copyOf(routes); } + } + public record Track(String id, List points, double nativeLength) { + public Track { points = List.copyOf(points); } + } + public record RouteSection(String routeId, int fromStop, int toStop, Optional pathVariant) { + public RouteSection { Objects.requireNonNull(routeId); pathVariant = Objects.requireNonNull(pathVariant); } + } + /** routeId can be empty for a physical-rail selection. */ + public record SelectedSpan(String routeId, RailSpan rail) { + public SelectedSpan { Objects.requireNonNull(routeId); Objects.requireNonNull(rail); } + } + public record Resolution(Status status, long catalogRevision, List spans, List candidates) { + public Resolution { spans = List.copyOf(spans); candidates = List.copyOf(candidates); } + } + public enum Purpose { FOCUS, DECORATION } + public enum Unmatched { KEEP, DIM, HIDE } + public enum Pattern { INHERIT, SOLID, COLOUR_WHITE } + public record Style(Optional color, double opacity, double widthScale, Pattern pattern) { + public Style { + Objects.requireNonNull(color); Objects.requireNonNull(pattern); + if (!Double.isFinite(opacity) || opacity < 0 || opacity > 1 || !Double.isFinite(widthScale) || widthScale < 0.25 || widthScale > 4) + throw new IllegalArgumentException("Invalid style"); + } + public static Style inherit() { return new Style(Optional.empty(), 1, 1, Pattern.INHERIT); } + public static Style dimmed(double opacity) { return new Style(Optional.empty(), opacity, 1, Pattern.INHERIT); } + } + /** A local override in native rail distance; later overrides win at overlapping points. */ + public record SpanStyle(SelectedSpan span, Style style) { + public SpanStyle { Objects.requireNonNull(span); Objects.requireNonNull(style); } + } + public record ViewRequest(WorldRef world, long catalogRevision, List spans, + Set wholeRoutes, Unmatched unmatched, Map routeStyles, + boolean showTrains, Set stationIds, List spanStyles, double unmatchedOpacity) { + public ViewRequest(WorldRef world, long catalogRevision, List spans, Set wholeRoutes, + Unmatched unmatched, Map routeStyles, boolean showTrains, Set stationIds) { + this(world, catalogRevision, spans, wholeRoutes, unmatched, routeStyles, showTrains, stationIds, List.of(), .15); + } + public ViewRequest { + Objects.requireNonNull(world); Objects.requireNonNull(unmatched); + spans = List.copyOf(spans); wholeRoutes = Set.copyOf(wholeRoutes); routeStyles = Map.copyOf(routeStyles); stationIds = Set.copyOf(stationIds); + spanStyles = List.copyOf(spanStyles); + if (spans.size() > 20000 || spanStyles.size() > 20000 || stationIds.size() > 20000 || wholeRoutes.size() > 4096 || routeStyles.size() > 4096) throw new IllegalArgumentException("View exceeds capacity"); + if (!Double.isFinite(unmatchedOpacity) || unmatchedOpacity < 0 || unmatchedOpacity > 1) throw new IllegalArgumentException("Invalid unmatched opacity"); + } + /** The caller supplies navigation progress. Overlay never infers movement or computes a route. */ + public static ViewRequest navigation(WorldRef world, long revision, List remaining, + List completed, double completedOpacity, double unusedOpacity) { + var all = new ArrayList(completed); all.addAll(remaining); + var overrides = new ArrayList(); + for (var span : completed) overrides.add(new SpanStyle(span, Style.dimmed(completedOpacity))); + // Remaining wins when a repeated/circular journey traverses the same physical interval again. + for (var span : remaining) overrides.add(new SpanStyle(span, Style.inherit())); + return new ViewRequest(world, revision, all, Set.of(), Unmatched.DIM, Map.of(), true, Set.of(), overrides, unusedOpacity); + } + } + public interface ViewSession extends AutoCloseable { + CompletionStage replace(ViewRequest request); + void clear(); + @Override void close(); + } + public record Arrival(String routeId, String routeName, int color, String platformId, String platformName, + String destination, long arrivalAt, long departureAt, long deviation, boolean realtime, boolean terminating) {} + public record Service(String routeId, String platformId, String routeName, int color, String platformName) {} + public record Board(WorldRef world, String stationId, long generation, Status status, long sampledAt, + long serverNow, List services, List arrivals) { + public Board { services = List.copyOf(services); arrivals = List.copyOf(arrivals); } + } + public interface BoardSubscription extends AutoCloseable { + Board latest(); + @Override void close(); + } + public record NavigationTarget(WorldRef world, String stationId) {} + public interface Registration extends AutoCloseable { @Override void close(); } + public interface Client { + default String apiVersion() { return "1.0.0"; } + Optional world(String dimensionId); + Catalog catalog(WorldRef world); + List tracks(WorldRef world, Set railIds); + CompletionStage resolve(WorldRef world, long catalogRevision, List sections); + ViewSession openView(String owner, String displayName, Purpose purpose, int priority); + BoardSubscription watchStation(WorldRef world, String stationId, Consumer listener); + Registration registerNavigation(String owner, String displayName, Consumer handler); + } +} diff --git a/build.gradle b/build.gradle index b405489..d85a4a2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,8 @@ version = project.mod_version group = project.maven_group base.archivesName = project.archives_base_name + '-forge-mc' + project.minecraft_version java.toolchain.languageVersion = JavaLanguageVersion.of(17) +ext.overlayRoot = projectDir +apply from: 'gradle/overlay-api.gradle' minecraft { mappings channel: 'official', version: project.minecraft_version runs { @@ -15,12 +17,12 @@ minecraft { workingDirectory project.file(project.findProperty('mtrmapRunDir') ?: 'run') if (project.hasProperty('quickplayServer')) args '--quickPlayMultiplayer', project.property('quickplayServer') else if (project.hasProperty('quickplay')) args '--quickPlaySingleplayer', project.property('quickplay') - mods { mtrmap { source sourceSets.main } } + mods { mtrmap { source sourceSets.main; source sourceSets.overlayApi } } } server { workingDirectory project.file(project.findProperty('mtrmapRunDir') ?: 'run-server') args 'nogui' - mods { mtrmap { source sourceSets.main } } + mods { mtrmap { source sourceSets.main; source sourceSets.overlayApi } } } } } @@ -82,6 +84,7 @@ if (project.hasProperty('mtrmapRuntimeTest')) { def mergedSmokeMod = tasks.register('mergeSmokeMod', Sync) { dependsOn tasks.named('classes') from sourceSets.main.output + from sourceSets.overlayApi.output into layout.buildDirectory.dir('runtime-mod') } tasks.withType(JavaExec).configureEach { diff --git a/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java b/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java index b694a40..097dc79 100644 --- a/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java +++ b/compat/mc1204/common/java/io/github/teamcreating/mtrmap/integration/journeymap/JourneyMapForegroundRenderer.java @@ -55,50 +55,24 @@ public static void render(GuiGraphics graphics, Object screen) { graphics.pose().translate(0, 0, 400); RenderSystem.setShaderColor(1, 1, 1, 1); scene = JourneyMapPathManager.render(graphics, MapDataCache.get(dimensionId), projection, - MTRMapConfig.INSTANCE.trainCarsEnabled.get() && bounds != null && MTRMapConfig.INSTANCE.enabled.get() + MTRMapConfig.INSTANCE.trainCarsEnabled.get() && io.github.teamcreating.mtrmap.extension.ViewRenderer.trains(dimensionId) && bounds != null && MTRMapConfig.INSTANCE.enabled.get() ? ClientTrainStream.cars(dimensionId, bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ(), projection.pixelsPerBlock()) : java.util.List.of()); graphics.flush(); - ResourceLocation currentTexture = null; - int currentColor = 0; - float currentOpacity = Float.NaN; - for (MarkerOverlay marker : JourneyMapLandmarkManager.displayedMarkers()) { - final MapImage icon = marker.getIcon(); - if (!viewedDimension.equals(marker.getDimension()) || icon.getImageLocation() == null) { - continue; - } - final int width = (int) Math.round(icon.getDisplayWidth()); - final int height = (int) Math.round(icon.getDisplayHeight()); - final int x = (int) Math.round(projection.x(marker.getPoint().getX(), marker.getPoint().getZ()) - - icon.getAnchorX()); - final int y = (int) Math.round(projection.y(marker.getPoint().getX(), marker.getPoint().getZ()) - - icon.getAnchorY()); - if (x + width < 0 || y + height < 0 || x >= graphics.guiWidth() || y >= graphics.guiHeight()) { - continue; - } - final int color = icon.getColor(); - final float opacity = icon.getOpacity(); - final ResourceLocation texture = icon.getImageLocation(); - if (!texture.equals(currentTexture) || color != currentColor || opacity != currentOpacity) { - // Buffered blits use the shader tint at flush time. Keep - // adjacent icons with the same state in one batch. - graphics.flush(); - RenderSystem.setShaderColor(((color >> 16) & 255) / 255f, - ((color >> 8) & 255) / 255f, (color & 255) / 255f, opacity); - currentTexture = texture; - currentColor = color; - currentOpacity = opacity; - } - graphics.blit(icon.getImageLocation(), x, y, width, height, - (float) icon.getTextureX(), (float) icon.getTextureY(), - icon.getTextureWidth(), icon.getTextureHeight(), - icon.getTextureWidth(), icon.getTextureHeight()); - } } finally { graphics.flush(); graphics.pose().popPose(); RenderSystem.setShaderColor(1, 1, 1, 1); } - if (scene != null) { + final double uiMouseX=minecraft.mouseHandler.xpos()/guiScale, uiMouseY=minecraft.mouseHandler.ypos()/guiScale; + graphics.pose().pushPose(); + try { + graphics.pose().translate(0,0,600); + io.github.teamcreating.mtrmap.extension.OverlayUi.render(graphics,dimensionId, + io.github.teamcreating.mtrmap.extension.ViewRenderer.data(MapDataCache.get(dimensionId)),projection.pixelsPerBlock(), + projection::x,projection::y,(int)uiMouseX,(int)uiMouseY,true); + graphics.flush(); + } finally { graphics.pose().popPose(); } + if (scene != null && !io.github.teamcreating.mtrmap.extension.OverlayUi.blocks(uiMouseX,uiMouseY) && !io.github.teamcreating.mtrmap.extension.OverlayUi.hasPoint(uiMouseX,uiMouseY)) { ClientTrainStream.drawStatus(graphics, dimensionId); final double mouseX = minecraft.mouseHandler.xpos() / guiScale; final double mouseY = minecraft.mouseHandler.ypos() / guiScale; diff --git a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java new file mode 100644 index 0000000..68ad826 --- /dev/null +++ b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java @@ -0,0 +1,18 @@ +package io.github.teamcreating.mtrmap.extension; +import io.github.teamcreating.mtrmap.MTRMap; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.client.event.ScreenEvent; + +@Mod.EventBusSubscriber(modid = MTRMap.MOD_ID, value = Dist.CLIENT) +public final class OverlayUiEvents { + private OverlayUiEvents() {} + @SubscribeEvent(priority = net.minecraftforge.eventbus.api.EventPriority.LOWEST) public static void render(ScreenEvent.Render.Post e) { + if (e.getScreen().getClass().getName().equals("xaero.map.gui.GuiMap")) io.github.teamcreating.mtrmap.integration.xaero.XaeroRouteRenderer.renderUi(); + } + @SubscribeEvent public static void down(ScreenEvent.MouseButtonPressed.Pre e) { if (OverlayUi.down(e.getMouseX(), e.getMouseY(), e.getButton())) e.setCanceled(true); } + @SubscribeEvent public static void up(ScreenEvent.MouseButtonReleased.Pre e) { if (OverlayUi.up(e.getMouseX(), e.getMouseY(), e.getButton())) e.setCanceled(true); } + @SubscribeEvent public static void wheel(ScreenEvent.MouseScrolled.Pre e) { if (OverlayUi.wheel(e.getMouseX(), e.getMouseY(), e.getDeltaY())) e.setCanceled(true); } + @SubscribeEvent public static void key(ScreenEvent.KeyPressed.Pre e) { if (OverlayUi.key(e.getKeyCode(), e.getModifiers())) e.setCanceled(true); } +} diff --git a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java index c3ce30c..25f3436 100644 --- a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java +++ b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java @@ -13,7 +13,14 @@ public final class MTRNetwork { .networkProtocolVersion(8).optional().simpleChannel(); public static final SimpleChannel TRAINS = ChannelBuilder.named(new ResourceLocation(MTRMap.MOD_ID, "trains_v1")) .networkProtocolVersion(1).optional().simpleChannel(); + public static final SimpleChannel OVERLAY = ChannelBuilder.named(new ResourceLocation(MTRMap.MOD_ID, "overlay_v1")).networkProtocolVersion(1).optional().simpleChannel(); public static void register() { + OVERLAY.messageBuilder(OverlayRequest.class, 0, NetworkDirection.PLAY_TO_SERVER) + .encoder((m,b)->OverlayRequest.write(b,m)).decoder(OverlayRequest::read) + .consumerMainThread((m,ctx)->{ var player = ctx.getSender(); if(player!=null) io.github.teamcreating.mtrmap.extension.ServerOverlayService.request(player,m); }).add(); + OVERLAY.messageBuilder(OverlayChunk.class, 1, NetworkDirection.PLAY_TO_CLIENT) + .encoder((m,b)->OverlayChunk.write(b,m)).decoder(OverlayChunk::read) + .consumerMainThread((m,ctx)->MTRNetworkClient.onOverlay(m)).add(); TRAINS.messageBuilder(TrainStreamRequest.class, 0, NetworkDirection.PLAY_TO_SERVER) .encoder((msg, buf) -> TrainStreamRequest.write(buf, msg)).decoder(TrainStreamRequest::read) .consumerMainThread((msg, ctx) -> { ServerPlayer sender = ctx.getSender(); if(sender != null) io.github.teamcreating.mtrmap.train.ServerTrainStream.request(sender, msg); }).add(); @@ -43,8 +50,10 @@ public static void register() { .encoder((msg, buf) -> NetworkProbeResponse.write(buf, msg)).decoder(NetworkProbeResponse::read) .consumerMainThread((msg, ctx) -> MTRNetworkClient.onProbe(msg)).add(); } + public static boolean canSendOverlay(ServerPlayer player) { return OVERLAY.isRemotePresent(player.connection.getConnection()); } public static boolean canSendTrains(ServerPlayer player) { return TRAINS.isRemotePresent(player.connection.getConnection()); } public static void sendToPlayer(ServerPlayer player, MapPacket packet) { + if(packet instanceof OverlayChunk) { if(canSendOverlay(player)) { OVERLAY.send(packet, player.connection.getConnection()); } return; } if (packet instanceof TrainStreamChunk || packet instanceof TrainStreamStatus) { if (canSendTrains(player)) { TRAINS.send(packet, player.connection.getConnection()); } return; diff --git a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java index 5c176a2..5e68a1f 100644 --- a/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java +++ b/compat/mc1204/forge/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java @@ -2,6 +2,8 @@ import net.minecraft.client.Minecraft; /** Physical-client callbacks. Never executed on a dedicated server. */ public final class MTRNetworkClient { + static void onOverlay(OverlayChunk packet) { io.github.teamcreating.mtrmap.extension.ClientOverlayRuntime.INSTANCE.chunk(packet); } + public static boolean canSendOverlay() { var c=Minecraft.getInstance().getConnection(); return c!=null && MTRNetwork.OVERLAY.isRemotePresent(c.getConnection()); } static void onChunk(NetworkSyncChunk packet) { ClientNetworkSync.onChunkReceived(packet); } static void onProbe(NetworkProbeResponse packet) { ClientNetworkSync.onProbeReceived(packet.hashes()); } static void onTrainChunk(TrainStreamChunk packet) { io.github.teamcreating.mtrmap.train.ClientTrainStream.chunk(packet); } @@ -13,6 +15,6 @@ public static boolean canSendToServer() { } public static void sendToServer(MapPacket packet) { var listener = Minecraft.getInstance().getConnection(); - if (listener != null) { if (packet instanceof TrainStreamRequest) MTRNetwork.TRAINS.send(packet, listener.getConnection()); else MTRNetwork.CHANNEL.send(packet, listener.getConnection()); } + if (listener != null) { if (packet instanceof OverlayRequest) MTRNetwork.OVERLAY.send(packet, listener.getConnection()); else if (packet instanceof TrainStreamRequest) MTRNetwork.TRAINS.send(packet, listener.getConnection()); else MTRNetwork.CHANNEL.send(packet, listener.getConnection()); } } } diff --git a/docs/agents/MESSAGES.md b/docs/agents/MESSAGES.md index 77c37c8..694de4d 100644 --- a/docs/agents/MESSAGES.md +++ b/docs/agents/MESSAGES.md @@ -1,5 +1,16 @@ # MESSAGES.md — Agent 交流墙(只追加,不改不删) +## [2026-10-26 13:32] Codex — 六平台基础实机回归补齐,保留Forge49单人失败 + +- 2026-10-26 13:32,Codex:补齐Fabric1.21.1和四旧目标的computer-use实机回归,六平台双地图均完成站点折叠/展开、原生ETA和英式/欧式样式检查;各loader滚轮和悬停检查通过。Forge1.20.4单人quick-play遇到Xaero serverData为空而断线,独立本地服务器连接后的双地图通过,单人问题保留待查。未改生产代码,功能源码仍main c1e0314 / compat e041b0a;未发布。详见[验证](../validation/pids-and-dimmed-1.5.3.md)。真实导航provider端到端和大网性能仍未测。 +- 所有测试客户端已正常退出;Forge49独立服用本地RCON stop保存并退出。独立副本和失败/成功日志保留,未修改用户原存档或正式附件。 + +## [2026-10-26 10:36] Codex — 1.5.3原生PIDS与dimmed移植完成 + +- 四目标支持主线新增站点原生API、独立overlay-1通道、旧式折叠信息与双主题PIDS、持久命令、精确区段dimmed和统一导航provider入口;版本1.5.3,不覆盖主线NF平台。不增加模拟或第三方专用适配。 +- 每目标104测试/构建、Java17 API ABI/消费者和生产JAR校验通过,另有20项CI工具回归。MTR4.0原生Simulator夹具直接验证路径/停站/到站接口。旧四目标本轮没有新PIDS实机;NeoForge两地图已检查,验证限制明确记录。源分支feat/1.5.3-station-board-mc120,准备draft PR;未发布。 + + ## [2026-10-22 22:42] Codex-CI — CI/CD六平台与发布演练验收 diff --git a/docs/agents/STATE.md b/docs/agents/STATE.md index 238a9b2..c13f7d7 100644 --- a/docs/agents/STATE.md +++ b/docs/agents/STATE.md @@ -1,8 +1,10 @@ # STATE.md — MC 1.20 兼容分支权威状态 -- 最后更新:2026-10-22 18:21,操作者:Codex。 +- 最后更新:2026-10-26 13:32,操作者:Codex。 +- 2026-10-26 13:32,Codex:补齐Fabric1.21.1和四旧目标的computer-use实机回归,六平台双地图均完成站点折叠/展开、原生ETA和英式/欧式样式检查;各loader滚轮和悬停检查通过。Forge1.20.4单人quick-play遇到Xaero serverData为空而断线,独立本地服务器连接后的双地图通过,单人问题保留待查。未改生产代码,功能源码仍main c1e0314 / compat e041b0a;未发布。详见[验证](../validation/pids-and-dimmed-1.5.3.md)。真实导航provider端到端和大网性能仍未测。 - 分支:`compat/mtr-4.0-mc-1.20`;原基于 main `6afde36`(v1.5.1),现移植 main `df9df96` 最新功能。 -- 版本:**v1.5.2**(用户指定,禁止自动递增);mod ID `mtrmap`;包 `io.github.teamcreating.mtrmap`;客户端命令 `/mtrmap`。 +- 开发版本:**v1.5.3**(用户指定,禁止自动递增);mod ID `mtrmap`;包 `io.github.teamcreating.mtrmap`;客户端命令 `/mtrmap`。 +- 2026-10-26 10:36,Codex:站点原生快照、Java17 API、英式/欧式PIDS/持久命令、默认折叠旧信息和导航dimmed已移植至feat/1.5.3-station-board-mc120;Forge47/49网络和滚轮API及JM5桥独立适配。四目标各104测试、构建、产物及API消费者通过;此处10:36的验收尚未包含旧目标新PIDS实机;后续补测及Forge49单人限制见上方最新QA记录。见[验证](../validation/pids-and-dimmed-1.5.3.md);未发布。 - 主线继续保留 NeoForge/Fabric MC 1.21.1,独立 worktree `../mtrsurveyor` 保留 1.21.1;本轮主线另修复 Fabric JourneyMap 入口,不以兼容代码覆盖主线。 - 2026-10-22 18:11,Codex:用户指定命名空间迁移至 `io.github.teamcreating.mtrmap`,maven_group=`io.github.teamcreating`;四目标各89项测试及产物检查通过,无旧包类/入口,原始LICENSE已打包。Java文件仅包名差异,mod ID/配置/网络协议保持。Fabric1.20.4独立服/JourneyMap5实际加载、绘制、悬停通过;迁移验收时未发布;现已按用户要求替换GitHub v1.5.2附件,tag保留。 diff --git a/docs/agents/TASKS.md b/docs/agents/TASKS.md index 2dd271e..94f8241 100644 --- a/docs/agents/TASKS.md +++ b/docs/agents/TASKS.md @@ -6,6 +6,13 @@ ## 进行中 +### [进行中,Codex-Release,2026-10-26 13:45] v1.5.3正式发布 +- 用户明确授权发布;核对六包/源码/命名,准备中英文说明与上次发布diff,经现有Candidate/Publish流程发布;保留Forge49单人限制,不改版本或1.5.2附件。 + +### [待领,兼容QA] Forge1.20.4单人quick-play的Xaero初始化失败 +- Codex,2026-10-26 13:32:见验证文档的失败日志;独立服双地图PIDS通过,单人启动不算通过,需production/无Overlay对照定位。 + + ### [功能] 非 TRAIN 轨道渲染开关 - `TrackSampler` 目前只采样 TRAIN 模式轨道。增加配置项按 TransportMode 过滤(BOAT/CABLE_CAR 可选)。 - 验收:配置切换后地图轨道层随之变化。 @@ -19,6 +26,10 @@ ## 完成 +- [完成] 1.5.3四兼容目标双地图基础实机回归 — Codex,2026-10-26 13:32,见本条所在提交。Core4.0.5原生预测、默认折叠/展开、两主题/持久命令、滚轮与悬停通过;Forge49以独立服方式通过,单人Xaero serverData为空的失败保留并待定位。未改生产代码、版本或发布。 + +- [完成] 1.5.3站点API、双样式PIDS和导航dimmed移植 — Codex,2026-10-26 10:36。四目标104测试/构建/产物及Java17消费者通过,Forge47/49独立可选overlay通道、滚轮API和JM5桥适配;主线NeoForge两地图已测。旧目标新UI实机/真实provider联动仍待发布前QA,详见docs/validation/pids-and-dimmed-1.5.3.md。未发布。 + - [完成] GitHub Actions CI/CD — Codex-CI,2026-10-22 22:42。双分支六目标Runner构建与CI Gate、18项工具回归、远端产物复核、六包Candidate、Nightly完整验证/无变更跳过、Snapshot六包与Publish只读演练通过;Environment已配置,规则在本验收提交推送后启用。RELEASE_TOKEN按用户要求稍后配置,不触发正式发布。实现提交main 9ea00e9、compat 7f63367,证据见docs/validation/ci-cd.md。 diff --git a/docs/api/overlay-api-v1.md b/docs/api/overlay-api-v1.md new file mode 100644 index 0000000..cf1638f --- /dev/null +++ b/docs/api/overlay-api-v1.md @@ -0,0 +1,53 @@ +# MTR Map Overlay client API 1.0.0 (1.5.3) + +The implementation is `io.github.teamcreating.mtrmap.api.MtrMapApi`. Use the separately built `build/api/mtrmap-api-1.0.0.jar` as **compileOnly**; do not shade or duplicate its classes. All API types are pure Java 17. Check that Overlay is installed before loading your integration class, then use `MtrMapApi.findClient()`. No Navigation/howtogo-specific dependency is required. + +## Thread and lifecycle + +Call all client operations and close handles on the Minecraft client thread. Listeners run on that thread. Values are immutable snapshots. `world("minecraft:overworld")` starts an optional catalog request and returns empty until a server-backed world identity is available. Require `catalog(world).status() == READY` before submitting a view. World identity includes a server epoch: do not reuse handles, IDs or revisions after disconnect/reconnect. View and station subscription handles are invalidated on disconnect; navigation provider registrations remain registered until explicitly closed, so a mod registers its entry once after findClient() becomes available (for example, on a client tick). + +The static v8 and train v1 transports remain unchanged. Station/catalog requests use a separate optional overlay-1 transport. Unsupported servers keep ordinary map functionality. A station board is LOADING, READY, LIMITED, STALE, NOT_FOUND, UNSUPPORTED or ERROR; LIMITED is not proof that no train exists. Native `arrivalAt`, `departureAt`, `deviation`, `realtime` and `terminating` values are preserved. Do not add deviation to timestamps a second time. + +## Native sections and dimming + +Use native route IDs and stop ordinals from Catalog. IDs must not be inferred from names/colours. `resolve(world, revision, sections)` clips already-existing Core Depot paths; it never runs another route finder. Multiple path variants produce AMBIGUOUS and candidate IDs. Explicitly select the intended variant. Rail fractions use canonical rail direction and native distance (including slopes), not X/Z chord length. Rail IDs retain Core's exact hex spelling; station/platform/route IDs use unsigned lowercase unpadded hex. + +```java +// Called by your planner on the client thread, after its navigation/progress changed. +var request = MtrMapApi.ViewRequest.navigation( + world, catalog.revision(), remainingSpans, completedSpans, + 0.35, // completed intervals retain colour at 35% opacity + 0.15 // unused routes/railways at 15% opacity +); +var result = focus.replace(request); // CompletionStage +``` + +Open `focus` with `client.openView("yourmod:journey", "Your planner", Purpose.FOCUS, 0)`. `remainingSpans` and `completedSpans` are lists of SelectedSpan supplied by the caller. The factory selects both and attaches completed-span overrides, with `unmatched=DIM`. Upcoming intervals win if a later journey traverses a completed physical interval again. Overlay does not infer progress or simulate a train. Cancel the journey by closing **your** focus handle; the next valid view or normal map is restored. Replacing a view is atomic: an invalid revision/selection leaves the previous valid view installed. The user can temporarily show the full map without destroying the caller's session. + +For finer control, construct a ViewRequest with `spanStyles` and `unmatchedOpacity`. Each `SpanStyle(SelectedSpan, Style)` applies to only that native interval; empty routeId means physical rail/all its bands. Last overlapping override wins. A local colour/pattern may inherit the route-wide override; opacity and width multiply the route-wide style, with final width clamped to [.25, 4]. Unmatched DIM additionally multiplies by unmatchedOpacity. Route bands, white blocks, feathering and the physical rail backing dim together; shared backing uses the most visible band. Train symbols retain their original appearance. Static source data is never mutated. Selections/overrides are bounded to 20000 entries each, with at most 256 combined intervals per physical rail across active sessions. Geometry is cached until a view/source change, so submit progress changes rather than an identical request every frame. + +`Unmatched.KEEP`, `DIM`, `HIDE`, explicit `wholeRoutes`, `stationIds`, colour/opacity/width and `INHERIT`, `SOLID`, `COLOUR_WHITE` patterns are implemented. An empty stationIds set retains all markers. No arbitrary clipping rectangle, custom dash/outline renderer, arbitrary UI injection or Maven repository publication is promised by this version. The earlier draft remains a design history, not a callable contract. + +## Reserved navigation entry + +```java +// Register once. Keep and close the returned registration when unloading the integration. +var registration = client.registerNavigation( + "yourmod:planner", "Your planner", + target -> openYourPlanner(target.world(), target.stationId()) +); +``` + +A user's Navigate click passes only the selected server-backed station identity to the handler. One provider is invoked directly; multiple providers show a chooser. Without a provider the reserved action is disabled. Registration never performs pathfinding, starts navigation automatically, or inspects another mod. Up to eight unique owner IDs may register. Closing a registration removes only that owner. Keep your own planner UI and use the view/session API after the user confirms a journey. + +## Station board and in-game preferences + +`client.watchStation(world, stationId, listener)` returns a BoardSubscription. Read latest() for its initial state; close it when no longer needed. Up to six different client station interests share bounded, renewable server snapshots. The built-in UI subscribes only while its board is expanded and the map is open; collapse/close releases its interest. + +Both map hosts use the same classic station tooltip, original route metadata, default collapsed state, Expand, and optional Navigate action. Expanded tables put destination first, then line, platform and right-aligned countdown. Long labels remain accessible in a row tooltip. Small windows compact the duplicated metadata and scroll rows without zooming the map. Prediction warnings are separate from a missing service row. No prediction is displayed as a dash, not zero minutes. + +- `/mtrmap config pidsStyle` queries the setting. +- `/mtrmap config pidsStyle british` selects black/amber, the default. +- `/mtrmap config pidsStyle european` selects white text, a blue-gray header, subtle alternating rows and amber countdowns. + +The preference persists in the loader's existing local config. It changes presentation only, with identical native arrival data. diff --git a/docs/releases/v1.5.3.md b/docs/releases/v1.5.3.md new file mode 100644 index 0000000..c58b753 --- /dev/null +++ b/docs/releases/v1.5.3.md @@ -0,0 +1,70 @@ +Compared with v1.5.2, this release adds native station arrival boards, two PIDS themes and a public API for third-party navigation views. + +## Added + +- **Pinned station information and native arrivals.** Click a station to keep its original station/platform/route information open. The card starts collapsed; **Expand** shows the next available services by destination, line and platform, using the same MTR/TSC arrival-data interface used for PIDS. Closing or collapsing the board releases its subscription. Missing predictions are displayed explicitly rather than as zero-minute arrivals. +- **British and European/airport PIDS styles.** British black/amber is the default. Use `/mtrmap config pidsStyle british` or `/mtrmap config pidsStyle european` to switch immediately and save the choice. Omit the style argument to query the current setting. +- **A unified navigation/view API.** Third-party mods can read native station/route/stop/path identifiers, resolve route sections, open owned view sessions and subscribe to station boards. They can highlight upcoming sections while independently dimming completed sections and unused lines, including different sections of the same physical rail. The **Navigate** entry supports registered providers and a chooser when several are available. It is disabled when no provider is installed; this release does not include an MTR-Navigation/howtogo-specific adapter. + +## Optimized and fixed + +- Reused native MTR/TSC arrival predictions without adding a client-side transit simulation. Station interests share server collection/encoding, with bounded leases, queues, paced chunks and fair transmission across players and interests. Clients decode in the background and reject stale snapshots. +- Preserved the classic station card and Xaero ROUTES/TRACKS controls. Improved narrow-window summaries, long-text hover details, aligned PIDS columns and ETA readability. Panel scrolling and clicks are intercepted before map zoom/pan actions. +- Fixed rail layers and the player arrow appearing over station panels. Retained separate Forge 47/49 input/network adapters and JourneyMap 5/6 rendering bridges. +- Navigation dimming applies consistently to route colours, high-speed white blocks, feathered edges and physical backing. Overlapping upcoming sections remain bright even when an earlier navigation leg uses the same track. +- All six builds include these features. Whole-map live trains, native XYZ storage and pure 2D rendering remain available; vehicle-hover ETA/next-stop distance is still deferred. + +## 中文更新说明 + +相较 v1.5.2,新增原生站点到站板、英式/欧式两套 PIDS,以及供第三方导航模组接入的统一地图视图 API。 + +### 新增功能 + +- **站点点击固定与原生到站预测**:保留原站点、站台和线路信息,默认折叠;点击 **Expand** 后按目的地、线路和站台显示下一班可用服务。直接使用 MTR/TSC 给 PIDS 的到站数据接口;无预测明确显示缺失,折叠或关闭后释放订阅。 +- **两种 PIDS 样式**:默认英式黑底琥珀字,可用 `/mtrmap config pidsStyle british|european` 即时切换并保存;不带样式参数可查询当前设置。 +- **统一导航/显示接口**:第三方可读取原生站点、线路、停站序号和路径目录,解析区段并控制独立视图。待乘区段保持明亮,已走区段和未用线路可分别变暗,同一物理轨道也能按区段控制。预留 **Navigate** 入口及多 provider 选择;没有 provider 时禁用。本版未内置特定导航模组的适配器。 + +### 优化与修复 + +- 服务端共享原生预测采集和编码,增加租约、容量限制、后台队列、分片节流与玩家/订阅轮转;客户端后台解码并拒绝旧快照,不维护第二套行车模拟。 +- 恢复旧式站点信息框与 Xaero ROUTES/TRACKS 控件;改善小窗口摘要、长文本悬停、表格列对齐和 ETA 显示,防止面板操作误触地图缩放/拖动。 +- 修复轨道和玩家箭头覆盖站点面板的问题,保留 Forge 47/49 和 JourneyMap 5/6 的独立兼容处理。 +- 导航 dimmed 同时作用于线路色、高铁白块、边缘及物理底衬;后续乘车区段重复使用同一轨道时保持明亮。 +- 六个平台同步提供本次功能;继续保留全图列车、真实 XYZ 和纯 2D 绘制。列车悬停 ETA/下一站距离仍暂缓。 + +## Choose the matching file / 下载环境 + +Install **one** mod JAR matching the exact Minecraft version, MTR version and loader. Native station arrivals and whole-map trains require matching client/server Overlay versions; client-only installations cannot provide the server snapshot service. + +按游戏版本、MTR 版本和加载器选择**一个** JAR。原生站点预测及全图列车需客户端和服务端安装匹配版本;仅客户端安装无法提供服务端快照服务。 + +| File | Minecraft | Loader | MTR | Java | +| --- | --- | --- | --- | --- | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.21.1-MTR4.1.0-beta.2-neoforge.jar` | 1.21.1 | NeoForge 21.1.x | 4.1.0-beta.2 | 21 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.21.1-MTR4.1.0-beta.2-fabric.jar` | 1.21.1 | Fabric | 4.1.0-beta.2 | 21 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.1-MTR4.0.5-forge.jar` | 1.20.1 | Forge 47.x | 4.0.5 | 17 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.1-MTR4.0.5-fabric.jar` | 1.20.1 | Fabric | 4.0.5 | 17 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.4-MTR4.0.5-forge.jar` | 1.20.4 | Forge 49.x | 4.0.5 | 17 | +| `CRTools-MTR-Map-Overlay-1.5.3-MC1.20.4-MTR4.0.5-fabric.jar` | 1.20.4 | Fabric | 4.0.5 | 17 | + +## Validation and known limits / 验证与已知限制 + +Six-target builds, JAR checks, shared unit tests and public Java 17 API consumer checks passed. Development-runtime PIDS checks covered Xaero and JourneyMap on all six targets. This is distinct from a production-JAR/large-modpack acceptance claim; see the manual candidate record for the exact release-binary validation performed. + +**Forge 1.20.4 singleplayer quick-play failed during testing:** Xaero Minimap/WorldMap read a null `serverData` during player join and disconnected before the Overlay snapshot was applied. The root cause remains unconfirmed. The same target passed both-map PIDS checks when connected to a separate local server. Singleplayer quick-play is **not verified as working** in that tested combination. + +六目标构建、产物检查、共享单测和 Java17 API 消费者检查通过;六平台两地图均完成开发运行环境中的基础 PIDS 回归。此结论不等同 production JAR/大型整合包验收,具体发布二进制的验证范围以候选验收记录为准。 + +**Forge 1.20.4 单人 quick-play 存在已知启动问题**:测试时 Xaero 在玩家加入阶段读取空 `serverData`,于 Overlay 快照应用前断开连接,根因尚未确认。该目标连接独立本地服务器后的双地图 PIDS 测试通过;不能据此声称单人启动已通过。 + +Real third-party navigation-provider integration, large-network/multi-viewer performance and cross-machine play have not been validated. No FPS/bandwidth benchmark is claimed. 原生预测覆盖不足会明确显示缺失,不应理解为没有列车或零 ETA;真实第三方导航联动、大网/多观看者性能和跨机器联机仍未实测。 + +[Detailed validation / 完整验证记录](https://github.com/teamCreating/MTR-Map-Overlay/blob/v1.5.3/docs/validation/pids-and-dimmed-1.5.3.md) · [Public API / 公共接口](https://github.com/teamCreating/MTR-Map-Overlay/blob/v1.5.3/docs/api/overlay-api-v1.md) + +## Changes from the previous release / 与上一次 release 的 diff + +[Full changelog: v1.5.2 → v1.5.3](https://github.com/teamCreating/MTR-Map-Overlay/compare/v1.5.2...v1.5.3) + +[Minecraft 1.20 compatibility diff: v1.5.2-mc1.20 → v1.5.3-mc1.20](https://github.com/teamCreating/MTR-Map-Overlay/compare/v1.5.2-mc1.20...v1.5.3-mc1.20) + +The refreshed 1.5.2 namespace binaries were built from main `959b7e4da470bf8a1315877a972189cce7c8e776` / compat `54a0acd`; the original release tags were retained. [Diff from the actual refreshed main source / 与1.5.2更新附件实际源码比较](https://github.com/teamCreating/MTR-Map-Overlay/compare/959b7e4da470bf8a1315877a972189cce7c8e776...v1.5.3). diff --git a/docs/validation/pids-and-dimmed-1.5.3.md b/docs/validation/pids-and-dimmed-1.5.3.md new file mode 100644 index 0000000..6b9ec9f --- /dev/null +++ b/docs/validation/pids-and-dimmed-1.5.3.md @@ -0,0 +1,62 @@ +# 1.5.3 站点PIDS与导航dimmed验证 + +Codex,2026-10-26 13:32(Asia/Taipei)。开发版本1.5.3;没有发布release/tag。旧1.5.2正式附件保持。功能源码仍为main `c1e0314` / compat `e041b0a`,本轮补实机QA,未改生产代码。 + +## 交付 + +- 两地图共用旧式紫边站点信息框,原站点/站台与线路描述保留。点击固定后默认折叠;Expand才订阅原生站点预测;Collapse、关闭地图或断线释放兴趣。小窗口压缩重复线路摘要,长文本可悬停查看完整内容,面板滚动不缩放地图。 +- 英式black/amber为默认;欧式/机场方向为白字、蓝灰表头、轻交替行和黄色ETA。固定Destination/Line/Platform/ETA列与行距,ETA右对齐。`/mtrmap config pidsStyle british|european`设置并持久化,无参数查询。 +- 可选Java17 API JAR、原生站点/线路/停站ordinal与Depot路径目录、显式路径歧义、可关闭的独立区段视图。`ViewRequest.navigation`同时选择待乘和已走区段;已走默认建议.35、未用.15,调用方可调整;重叠的后续乘车区段优先保持明亮。`spanStyles`精确到原生半轨,线路色、白块、羽化和物理底衬一同淡化,源缓存不修改。 +- Navigate预留入口通过owner注册回调接入;零provider禁用,多个provider可选择。没有Navigation/howtogo专用适配,没有本地寻路、导航进度推断或车辆模拟。 +- 静态v8和车辆v1不变;站点/目录使用独立可选overlay-1。服务器共享Simulator线程原生ArrivalsRequest读取及后台编码,客户端后台解码和展示;租约/容量/分片/错代/限额/玩家与兴趣轮转,长目录传输不独占所有站点发送机会。 + +## 构建与自动验证 + +| MC / loader | MTR | 构建 | JUnit | +| --- | --- | --- | --- | +| 1.21.1 NeoForge | 4.1.0-beta.2 | 通过 | 101通过、1生成用例跳过 | +| 1.21.1 Fabric | 4.1.0-beta.2 | 通过 | 共享主线101项;本目标不单独重复运行 | +| 1.20.1 Forge | 4.0.5 | 通过 | 104通过、1生成用例跳过 | +| 1.20.1 Fabric | 4.0.5 | 通过 | 104通过、1生成用例跳过 | +| 1.20.4 Forge | 4.0.5 | 通过 | 104通过、1生成用例跳过 | +| 1.20.4 Fabric | 4.0.5 | 通过 | 104通过、1生成用例跳过 | + +- 实际依赖编译验证Core4.0/4.1公开接口;原生Simulator夹具保留重复停站、Depot半轨及平台/站点映射。PIDS模型验证native arrivalAt,不重复加deviation;已到站、过期班次、无预测和同站台不同目的地保持区分。 +- 几何用例覆盖同一物理轨道的已走/待乘/未用三段、下一次复用同区段的优先级、被v8绘制缓存去重的原生线路身份、真实XYZ/LOD/长度、白块及底衬alpha和源快照不可变。 +- `python tools/verify_platform_matrix.py`六产物namespace、loader/MC/MTR metadata、LICENSE、字节码、Mixin和共享测试检查通过。新API始终Java17,其余主线Java21/兼容Java17;校验器仅对精确api/子包放行Java17,并禁止泄漏MC/Core/地图类型。 +- CI工具20项回归通过(包含新API/新输入桥六目标正例、API错误字节码与MC类型泄漏拒绝)。 +- 六目标独立API JAR与内嵌API公开签名对照及Java17消费者编译检查;Fabric remap可能重排常量池,不以逐字节相同代替ABI验证。 + +## computer-use实机范围 + +NeoForge1.21.1,独立存档副本`run/saves/Overlay153Test`,未修改用户原存档。已直接操作Xaero与JourneyMap6: + +- 默认旧信息框与折叠、点击Expand显示原生Local/Local Return倒计时及Express缺预测横杠;悬停能读完整平台名;保持固定、不因移出关闭。 +- 英式琥珀表、指令切换european的客户端反馈、欧式白字/底色/右对齐ETA;正常退出重启后仍保留european,证明持久化。 +- 小窗口压缩模式与最大化后的完整线路列表;旧ROUTES/TRACKS控件恢复;初版面板内部滚动不改变3.9x缩放。 +- JourneyMap轨道不再盖住卡片;Xaero初版玩家箭头穿过背景,最终改为host post-render/独立UI深度,重启复查箭头已被站点面板覆盖。 + +### 2026-10-26 补齐其余五目标 + +按用户要求直接使用computer-use,逐个启动客户端;同时只保留一个Minecraft图形客户端。新增独立存档副本,原测试存档与用户存档均保留。Gradle开发启动器运行当前源码,不能等同production JAR/整合包启动。 + +| MC / loader | MTR | Xaero 1.45.0 | JourneyMap | 运行方式 | +| --- | --- | --- | --- | --- | +| 1.21.1 NeoForge 21.1.249 | 4.1.0-beta.2 | 通过(前轮) | 6.0.8,通过(前轮) | 独立单人副本 | +| 1.21.1 Fabric 0.16.14 | 4.1.0-beta.2 | 通过 | 6.0.8,通过 | 独立单人副本 | +| 1.20.1 Fabric 0.16.14 | 4.0.5 | 通过 | 6.0.6,通过 | 独立单人副本 | +| 1.20.4 Fabric 0.16.14 | 4.0.5 | 通过 | 5.10.0,通过 | 独立单人副本 | +| 1.20.1 Forge 47.4.0 | 4.0.5 | 通过 | 6.0.6,通过 | 独立单人副本 | +| 1.20.4 Forge 49.2.0 | 4.0.5 | 独立服通过;单人启动失败 | 5.10.0,独立服通过 | loopback独立服;限制见下 | + +- 五目标均在Xaero检查旧式信息默认折叠、点击固定、Expand显示英式原生预测、Express缺预测横杠、Local/Local Return各自目的地与ETA、行悬停完整平台名、Escape关闭和面板内部滚轮不缩放地图。地图显示三条线路、六条轨道、七个landmark和一列全网车;多次观察可见车体位置变化。 +- 五目标均在实际游戏聊天栏执行`/mtrmap config pidsStyle european`,确认成功反馈,再打开JourneyMap点击站点并展开,核对欧式表头/交替行/黄色ETA、原生目的地、轨道与车辆不穿过面板。两个1.20.4目标额外核对JourneyMap5面板滚轮与行悬停;Forge1.20.1也检查JourneyMap6滚轮。Fabric1.21.1与其他旧目标的滚轮结论分别来自其Xaero检查,不声称逐地图执行所有相同操作。 +- 表格ETA持续变化,例如Fabric1.20.1的Local从16min降至12min、Forge1.20.4从6min降至3min/2min。它们是测试网络Core的原生预测,不能把长等待解释成客户端模拟或错误加时;仍未逐一对照世界内实体PIDS。 +- Fabric1.21.1本地目录:主线`build/runtime-qa-153/fabric-1.21.1/saves/Overlay153Test`;启动日志`build/runtime-qa-153/fabric-1211-launch.log`。其余目录:兼容worktree `.gradle/runtime-qa-153/-/saves/Overlay153Test`,日志`fabric-1201-launch.log`、`fabric-1204-launch.log`、`forge-1201-launch.log`。 +- Forge1.20.4首次单人quick-play失败,`forge-1204-launch.log`保留Xaero Minimap/WorldMap `CommonEvents.onPlayerWorldJoin`中`serverData == null`的异常和`Error loading server minimap properties. Please retry.`。失败发生于Overlay快照应用前,根因尚未确认;不能宣称单人支持已通过,也没有用独立服成功覆盖该失败。 +- 随后用只含MTR和Overlay的Forge49服务端绑定`127.0.0.1:25589`,同一版本客户端连接后双地图及原生PIDS通过。日志`forge-1204-server-launch.log` / `forge-1204-dedicated-client.log`及对应目录`logs/latest.log`。客户端正常退出,服务端用其本地RCON发送`stop`正常保存退出。RCON凭据不进文档或测试包。 +- 本轮没有新增Overlay运行时异常;旧目标MTR cargo loader/unloader loot-table错误仍存在,按上游资源问题单独记录。六目标功能源码对应的GitHub CI Gate均通过;自动检查与这里的UI证据分别计数。 + +仍未实测第三方真实provider点击/进度联动、大网络/多玩家性能、跨机器联机或production整合包启动。dimmed自动化覆盖完整几何与公开API,未声称完成真实导航模组适配/联动实测。测试夹具的缺预测服务显示LIMITED,不能解释为无车或零ETA。候选数上限不构成Core内部扫描耗时上限;不提供FPS/带宽实测成绩。 + +API实现与可编译示例见[公共API v1](../api/overlay-api-v1.md)。本地测试包统一放在`../test-builds/v1.5.3-pids-dimmed`,六文件名含MC/MTR/loader,并附API JAR、哈希与源码提交记录。 diff --git a/fabric/build.gradle b/fabric/build.gradle index 45f89a6..ae83f49 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -9,6 +9,9 @@ version = sharedProperties.getProperty('mod_version') group = sharedProperties.getProperty('maven_group') base.archivesName = sharedProperties.getProperty('archives_base_name') + '-fabric-mc' + mcVersion java.toolchain.languageVersion = JavaLanguageVersion.of(17) +ext.overlayRoot = file('..') +apply from: '../gradle/overlay-api.gradle' +loom.mods { mtrmap { sourceSet sourceSets.main; sourceSet sourceSets.overlayApi } } repositories { exclusiveContent { forRepository { mavenCentral() } @@ -48,6 +51,7 @@ sourceSets.main.resources.srcDir('../src/main/resources') sourceSets.main.resources.exclude('META-INF/mods.toml', 'mtrmap.mixins.json') compileJava.source(fileTree('../src/main/java') { exclude 'io/github/teamcreating/mtrmap/train/TrainServerEvents.java' + exclude 'io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java' exclude 'io/github/teamcreating/mtrmap/MTRMap.java' exclude 'io/github/teamcreating/mtrmap/MTRMapClient.java' exclude 'io/github/teamcreating/mtrmap/config/MTRMapConfig.java' @@ -132,6 +136,7 @@ if (legacyJourneyMap) { fileTree('../src/main/java') { exclude overrides exclude 'io/github/teamcreating/mtrmap/train/TrainServerEvents.java', 'io/github/teamcreating/mtrmap/MTRMap.java', 'io/github/teamcreating/mtrmap/MTRMapClient.java', + 'io/github/teamcreating/mtrmap/extension/OverlayUiEvents.java', 'io/github/teamcreating/mtrmap/config/MTRMapConfig.java', 'io/github/teamcreating/mtrmap/CommandRegistration.java', 'io/github/teamcreating/mtrmap/network/MTRNetwork.java', 'io/github/teamcreating/mtrmap/network/MTRNetworkClient.java', 'io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java' diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java index 6ddd3f1..be62ce2 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java @@ -50,6 +50,14 @@ public static void register(CommandDispatcher dispatc MTRMapConfig.INSTANCE.trainCarsEnabled::get, MTRMapConfig.INSTANCE.trainCarsEnabled::set)); + var pids = literal("pidsStyle").executes(ctx -> { + ctx.getSource().sendFeedback(Component.translatable("mtrmap.ui.pids_style", MTRMapConfig.INSTANCE.pidsStyle.get())); return 1; + }); + for (var style : io.github.teamcreating.mtrmap.config.PidsStyle.values()) pids.then(literal(style.key).executes(ctx -> { + MTRMapConfig.setPidsStyle(style.key); + ctx.getSource().sendFeedback(Component.translatable("mtrmap.ui.pids_style", style.key)); return 1; + })); + config.then(pids); dispatcher.register(literal("mtrmap") .then(mode) .then(config) diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java index 87c5f82..e9d1f8c 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMap.java @@ -16,9 +16,10 @@ public final class MTRMap implements ModInitializer { public void onInitialize() { MTRMapConfig.load(); MTRNetwork.registerServer(); - net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(io.github.teamcreating.mtrmap.train.ServerTrainStream::tick); - net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STOPPING.register(server -> io.github.teamcreating.mtrmap.train.ServerTrainStream.stop()); - net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> io.github.teamcreating.mtrmap.train.ServerTrainStream.disconnect(handler.player.getUUID())); + net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents.END_SERVER_TICK.register(server -> { io.github.teamcreating.mtrmap.train.ServerTrainStream.tick(server); io.github.teamcreating.mtrmap.extension.ServerOverlayService.tick(server); }); + net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STOPPING.register(server -> { io.github.teamcreating.mtrmap.train.ServerTrainStream.stop(); io.github.teamcreating.mtrmap.extension.ServerOverlayService.stop(); }); + net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> + { io.github.teamcreating.mtrmap.train.ServerTrainStream.disconnect(handler.player.getUUID()); io.github.teamcreating.mtrmap.extension.ServerOverlayService.disconnect(handler.player.getUUID()); }); LOGGER.info("[{}] Fabric loaded; JourneyMap {}, Xaero World Map {}", MOD_NAME, isModLoaded("journeymap") ? "detected" : "not found", isModLoaded("xaeroworldmap") ? "detected" : "not found"); diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java index b8e9480..bd06a2e 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/MTRMapClient.java @@ -12,6 +12,15 @@ public final class MTRMapClient implements ClientModInitializer { @Override public void onInitializeClient() { + net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.AFTER_INIT.register((client, screen, width, height) -> { + if(screen.getClass().getName().equals("xaero.map.gui.GuiMap")) + net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.afterRender(screen).register((s,g,x,y,delta)->io.github.teamcreating.mtrmap.integration.xaero.XaeroRouteRenderer.renderUi()); + net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.allowMouseClick(screen).register((s, x, y, button) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.down(x, y, button)); + net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.allowMouseRelease(screen).register((s, x, y, button) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.up(x, y, button)); + net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents.allowMouseScroll(screen).register((s, x, y, horizontal, vertical) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.wheel(x, y, vertical)); + net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.allowKeyPress(screen).register((s, key, scan, modifiers) -> !io.github.teamcreating.mtrmap.extension.OverlayUi.key(key, modifiers)); + }); + MTRNetwork.registerClient(); ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> CommandRegistration.register(dispatcher)); diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java index f0e70e7..c147adb 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java @@ -23,6 +23,7 @@ public final class MTRMapConfig { public final Value waypointMode = value("waypointMode", "both", s -> s); public final Value routeLinesEnabled = bool("routeLinesEnabled", true); public final Value trainCarsEnabled = bool("trainCarsEnabled", true); + public final Value pidsStyle = value("pidsStyle", "british", s -> PidsStyle.parse(s).key); public final Value trackLinesEnabled = bool("trackLinesEnabled", true); public final Value networkSyncEnabled = bool("networkSync.enabled", true); public final Value networkSyncIntervalSeconds = value("networkSync.refreshIntervalSeconds", 300, @@ -35,6 +36,7 @@ public final class MTRMapConfig { private MTRMapConfig() { } + public static void setPidsStyle(String style) { INSTANCE.pidsStyle.set(PidsStyle.parse(style).key); } private static Value bool(String key, boolean fallback) { return value(key, fallback, Boolean::parseBoolean); diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java index 8861cd3..edb1ca3 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/mixin/client/xaero/XaeroWorldMapMixin.java @@ -31,12 +31,4 @@ public abstract class XaeroWorldMapMixin { } } - @Inject(method = {"mouseClicked", "method_25402"}, at = @At("HEAD"), - remap = false, cancellable = true, require = 0) - private void mtrmap$onClick(double mouseX, double mouseY, int button, - CallbackInfoReturnable cir) { - if (XaeroRouteRenderer.onMouseClicked(mouseX, mouseY, button)) { - cir.setReturnValue(true); - } - } } diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java index 1531793..df7febd 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetwork.java @@ -9,6 +9,9 @@ /** Fabric 1.20 transport for the shared v8 messages. */ public final class MTRNetwork { + private static final ResourceLocation OVERLAY_REQUEST=id("overlay_request_v1"), OVERLAY_CHUNK=id("overlay_response_v1"); + public static boolean canSendOverlay(ServerPlayer p) { return ServerPlayNetworking.canSend(p,OVERLAY_CHUNK); } + public static boolean canSendOverlayToServer() { return ClientPlayNetworking.canSend(OVERLAY_REQUEST); } private static final ResourceLocation REQUEST = id("request_network_sync_v8"); private static final ResourceLocation PROBE = id("network_sync_probe_v8"); private static final ResourceLocation CHUNK = id("network_sync_chunk_v8"); @@ -19,6 +22,7 @@ public final class MTRNetwork { public static boolean canSendTrains(ServerPlayer player) { return ServerPlayNetworking.canSend(player, TRAIN_CHUNK) && ServerPlayNetworking.canSend(player, TRAIN_STATUS); } public static boolean canSendTrainsToServer() { return ClientPlayNetworking.canSend(TRAIN_REQUEST); } public static void registerServer() { + ServerPlayNetworking.registerGlobalReceiver(OVERLAY_REQUEST,(server,player,handler,buf,sender)->{ var message=OverlayRequest.read(buf);server.execute(()->io.github.teamcreating.mtrmap.extension.ServerOverlayService.request(player,message)); }); ServerPlayNetworking.registerGlobalReceiver(TRAIN_REQUEST, (server, player, handler, buf, sender) -> { TrainStreamRequest msg = TrainStreamRequest.read(buf); server.execute(() -> io.github.teamcreating.mtrmap.train.ServerTrainStream.request(player, msg)); @@ -31,6 +35,7 @@ public static void registerServer() { server.execute(() -> ServerNetworkCollector.sendProbeResponse(player))); } public static void registerClient() { + ClientPlayNetworking.registerGlobalReceiver(OVERLAY_CHUNK,(client,handler,buf,sender)->{ var message=OverlayChunk.read(buf);client.execute(()->io.github.teamcreating.mtrmap.extension.ClientOverlayRuntime.INSTANCE.chunk(message)); }); ClientPlayNetworking.registerGlobalReceiver(TRAIN_CHUNK, (client, handler, buf, sender) -> { TrainStreamChunk msg = TrainStreamChunk.read(buf); client.execute(() -> io.github.teamcreating.mtrmap.train.ClientTrainStream.chunk(msg)); @@ -53,7 +58,7 @@ public static boolean canSendToServer() { } public static void sendToServer(MapPacket packet) { FriendlyByteBuf buf = PacketByteBufs.create(); - if (packet instanceof TrainStreamRequest msg) { + if(packet instanceof OverlayRequest msg) { OverlayRequest.write(buf,msg);ClientPlayNetworking.send(OVERLAY_REQUEST,buf); } else if (packet instanceof TrainStreamRequest msg) { TrainStreamRequest.write(buf, msg); ClientPlayNetworking.send(TRAIN_REQUEST, buf); } else if (packet instanceof RequestNetworkSync msg) { RequestNetworkSync.write(buf, msg); ClientPlayNetworking.send(REQUEST, buf); @@ -64,7 +69,7 @@ public static void sendToServer(MapPacket packet) { public static void sendToPlayer(ServerPlayer player, MapPacket packet) { FriendlyByteBuf buf = PacketByteBufs.create(); ResourceLocation channel; - if (packet instanceof TrainStreamChunk msg) { + if(packet instanceof OverlayChunk msg) { OverlayChunk.write(buf,msg);channel=OVERLAY_CHUNK; } else if (packet instanceof TrainStreamChunk msg) { TrainStreamChunk.write(buf, msg); channel = TRAIN_CHUNK; } else if (packet instanceof TrainStreamStatus msg) { TrainStreamStatus.write(buf, msg); channel = TRAIN_STATUS; diff --git a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java index 74f8abc..5aea4f3 100644 --- a/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java +++ b/fabric/src/main/java/io/github/teamcreating/mtrmap/network/MTRNetworkClient.java @@ -8,6 +8,7 @@ public final class MTRNetworkClient { private MTRNetworkClient() { } + public static boolean canSendOverlay() { return MTRNetwork.canSendOverlayToServer(); } public static boolean canSendTrains() { return MTRNetwork.canSendTrainsToServer(); } public static boolean canSendToServer() { return MTRNetwork.canSendToServer(); diff --git a/fabric/src/main/resources/mtrmap.fabric.mixins.json b/fabric/src/main/resources/mtrmap.fabric.mixins.json index c3f75c2..6c3c03e 100644 --- a/fabric/src/main/resources/mtrmap.fabric.mixins.json +++ b/fabric/src/main/resources/mtrmap.fabric.mixins.json @@ -8,7 +8,8 @@ "client.MinecraftClientDataMixin", "client.xaero.XaeroWorldMapAccessor", "client.xaero.XaeroWorldMapMixin", - "client.xaero.TrainMapPauseMixin" + "client.xaero.TrainMapPauseMixin", + "client.OverlayPointerMixin" ], "mixins": [ "MainAccessorMixin", diff --git a/gradle.properties b/gradle.properties index b196f6b..4050bea 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx3G -Dfile.encoding=UTF-8 org.gradle.daemon=false minecraft_version=1.20.1 forge_version=47.4.0 -mod_version=1.5.2 +mod_version=1.5.3 maven_group=io.github.teamcreating archives_base_name=CRTools-MTR-Map-Overlay mtr_version=4.0.5 diff --git a/gradle/overlay-api.gradle b/gradle/overlay-api.gradle new file mode 100644 index 0000000..a749c90 --- /dev/null +++ b/gradle/overlay-api.gradle @@ -0,0 +1,21 @@ +// Pure Java 17 contract, shared by all loaders; implementation never leaks into this JAR. +sourceSets { + overlayApi { java.srcDir new File(project.ext.overlayRoot, 'api/src/main/java') } +} +tasks.named('compileOverlayApiJava', JavaCompile) { options.release = 17 } +sourceSets.main.compileClasspath += sourceSets.overlayApi.output +sourceSets.main.runtimeClasspath += sourceSets.overlayApi.output +sourceSets.test.compileClasspath += sourceSets.overlayApi.output +sourceSets.test.runtimeClasspath += sourceSets.overlayApi.output +tasks.named('compileJava') { dependsOn tasks.named('compileOverlayApiJava') } +tasks.named('jar') { from sourceSets.overlayApi.output } +tasks.register('apiJar', Jar) { + dependsOn tasks.named('compileOverlayApiJava') + archiveBaseName = 'mtrmap-api' + archiveVersion = '1.0.0' + archiveClassifier = '' + destinationDirectory = layout.buildDirectory.dir('api') + from sourceSets.overlayApi.output + from new File(project.ext.overlayRoot, 'LICENSE') +} +tasks.named('assemble') { dependsOn tasks.named('apiJar') } diff --git a/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java b/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java index b9d1de4..b3e64a0 100644 --- a/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java +++ b/src/main/java/io/github/teamcreating/mtrmap/CommandRegistration.java @@ -121,6 +121,16 @@ public static void register(CommandDispatcher dispatcher) { // Config sub-commands LiteralArgumentBuilder configNode = Commands.literal("config"); + var pids = Commands.literal("pidsStyle").executes(ctx -> { + ctx.getSource().sendSuccess(() -> Component.translatable("mtrmap.ui.pids_style", MTRMapConfig.INSTANCE.pidsStyle.get()), false); + return 1; + }); + for (var style : io.github.teamcreating.mtrmap.config.PidsStyle.values()) pids.then(Commands.literal(style.key).executes(ctx -> { + MTRMapConfig.setPidsStyle(style.key); + ctx.getSource().sendSuccess(() -> Component.translatable("mtrmap.ui.pids_style", style.key), false); + return 1; + })); + configNode.then(pids); configNode.then(createBoolConfigNode("enabled", "MTR map overlays", () -> MTRMapConfig.INSTANCE.enabled.get(), v -> MTRMapConfig.INSTANCE.enabled.set(v))); diff --git a/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java b/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java index 7c15b94..795c058 100644 --- a/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java +++ b/src/main/java/io/github/teamcreating/mtrmap/config/MTRMapConfig.java @@ -29,6 +29,7 @@ public class MTRMapConfig { public final ForgeConfigSpec.BooleanValue routeLinesEnabled; public final ForgeConfigSpec.BooleanValue trackLinesEnabled; public final ForgeConfigSpec.BooleanValue trainCarsEnabled; + public final ForgeConfigSpec.ConfigValue pidsStyle; // Full-network sync (requires the mod on the server) public final ForgeConfigSpec.BooleanValue networkSyncEnabled; @@ -76,6 +77,8 @@ private MTRMapConfig(ForgeConfigSpec.Builder builder) { trainCarsEnabled = builder.comment("Draw TSC global train symbols on the map").define("trainCarsEnabled", true); + pidsStyle = builder.define("pidsStyle", "british", value -> value instanceof String str && (str.equals("british") || str.equals("european"))); + networkSyncEnabled = builder .comment("Request full-network snapshots from servers that also run this mod (Create-train-map-style whole-network view). Client-only servers fall back to radius-limited MTR data automatically") .define("networkSync.enabled", true); @@ -109,6 +112,8 @@ private MTRMapConfig(ForgeConfigSpec.Builder builder) { builder.pop(); } + public static void setPidsStyle(String style) { INSTANCE.pidsStyle.set(PidsStyle.parse(style).key); SPEC.save(); } + public static void register(ModContainer modContainer) { migrateLegacyConfig(); ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, SPEC, "mtrmap.toml"); diff --git a/src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java b/src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java new file mode 100644 index 0000000..2bbf63b --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/config/PidsStyle.java @@ -0,0 +1,15 @@ +package io.github.teamcreating.mtrmap.config; + +/** Stable configuration values shared by loaders. */ +public enum PidsStyle { + BRITISH("british", 0xFFD34E, 0xD9CFAD, 0x181510, false), + EUROPEAN("european", 0xF2F3F5, 0xBFC6D1, 0x252E3C, true); + + public final String key; + public final int destination, secondary, header; + public final boolean stripes; + PidsStyle(String key, int destination, int secondary, int header, boolean stripes) { + this.key = key; this.destination = destination; this.secondary = secondary; this.header = header; this.stripes = stripes; + } + public static PidsStyle parse(String key) { return "european".equals(key) ? EUROPEAN : BRITISH; } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java b/src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java new file mode 100644 index 0000000..c57b945 --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/ClientOverlayRuntime.java @@ -0,0 +1,180 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.MTRMap; +import io.github.teamcreating.mtrmap.api.MtrMapApi; +import io.github.teamcreating.mtrmap.api.MtrMapApi.*; +import io.github.teamcreating.mtrmap.mapdata.MapDataCache; +import io.github.teamcreating.mtrmap.network.*; +import net.minecraft.client.Minecraft; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** Client-thread facade. Receiving snapshots never creates a local simulation. */ +public final class ClientOverlayRuntime implements MtrMapApi.Client { + public static final ClientOverlayRuntime INSTANCE = new ClientOverlayRuntime(); + private record Key(String dimension, String station) {} + private static final class Query { + final long generation; final long started = System.nanoTime(); + long requestAt, sequence, transferSequence; + OverlayAssembly transfer; + Query(long generation) { this.generation = generation; } + } + private final Map queries = new LinkedHashMap<>(); + private final Map catalogs = new HashMap<>(); + private final Map> watches = new LinkedHashMap<>(); + private long generation; + private final ViewRegistry views = new ViewRegistry(); + public record Navigation(String owner, String name, Consumer handler) {} + private final Map navigation = new LinkedHashMap<>(); + public List navigation() { thread(); return List.copyOf(navigation.values()); } + public Registration registerNavigation(String owner, String displayName, Consumer handler) { + thread(); + if (owner == null || !owner.matches("[a-z0-9_.-]+:[a-z0-9_./-]+") || displayName == null || displayName.isBlank() || displayName.length() > 80 || navigation.size() >= 8 || navigation.containsKey(owner)) + throw new IllegalArgumentException("Invalid or duplicate navigation provider / capacity reached"); + var value = new Navigation(owner, displayName, Objects.requireNonNull(handler)); navigation.put(owner, value); + // Providers live for the client/mod lifetime; world/session data is supplied only on an explicit user click. + return () -> { thread(); navigation.remove(owner, value); }; + } + public void navigate(Navigation provider, WorldRef world, String station) { + thread(); if (navigation.get(provider.owner()) != provider || catalog(world).status() != Status.READY) return; + try { provider.handler().accept(new NavigationTarget(world, station)); } + catch (RuntimeException e) { MTRMap.LOGGER.warn("[MTRMap] Navigation provider failed: {}", provider.owner(), e); } + } + private boolean clearing; + private final ThreadPoolExecutor decoder = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(2), r -> { var t = new Thread(r, "MTRMap-overlay-decoder"); t.setDaemon(true); return t; }); + private ClientOverlayRuntime() { MtrMapApi.install(this); } + private static void thread() { if (!Minecraft.getInstance().isSameThread()) throw new IllegalStateException("MTR Map API requires client thread"); } + public ViewRegistry views() { return views; } + private static String key(String dimension) { return dimension.replaceFirst(":", "/"); } + public Optional world(String dimensionId) { + thread(); if (Minecraft.getInstance().getConnection() == null) return Optional.empty(); + String dimension = key(dimensionId); + if (!dimension.matches("[a-z0-9_.-]+/[a-z0-9_./-]+")) throw new IllegalArgumentException("Invalid dimension"); + Catalog catalog = catalogs.get(dimension); + if (catalog == null && MTRNetworkClient.canSendOverlay() && queries.size() < 8) queries.computeIfAbsent(new Key(dimension, ""), k -> new Query(++generation)); + return catalog == null ? Optional.empty() : Optional.of(catalog.world()); + } + public Catalog catalog(WorldRef world) { + thread(); Catalog value = catalogs.get(key(world.dimensionId())); + if (value == null || !value.world().equals(world)) return new Catalog(world, 0, 0, Status.STALE, List.of(), List.of(), List.of()); + if (MapDataCache.hasServerData(key(world.dimensionId())) && MapDataCache.get(key(world.dimensionId())).version != value.baseStaticRevision()) + return new Catalog(value.world(), value.revision(), value.baseStaticRevision(), Status.STALE, value.stations(), value.platforms(), value.routes()); + return value; + } + public List tracks(WorldRef world, Set railIds) { + thread(); if (railIds.size() > 4096) throw new IllegalArgumentException("Too many rails"); + if (catalog(world).status() != Status.READY) return List.of(); + return MapDataCache.get(key(world.dimensionId())).tracks.stream().filter(t -> railIds.contains(t.id)) + .map(t -> new Track(t.id, t.points.stream().map(p -> new Point(p[0], p.length >= 3 ? p[2] : Double.NaN, p[1])).toList(), t.curve == null ? Double.NaN : t.curve.length())).toList(); + } + public CompletionStage resolve(WorldRef world, long revision, List sections) { + thread(); var catalog = catalog(world); + return CompletableFuture.completedFuture(catalog.status() != Status.READY || catalog.revision() != revision + ? new Resolution(Status.STALE, catalog.revision(), List.of(), List.of()) : SectionResolver.resolve(catalog, List.copyOf(sections))); + } + public ViewSession openView(String owner, String displayName, Purpose purpose, int priority) { + thread(); var delegate = views.open(owner, displayName, purpose, priority, this::catalog); + return new ViewSession() { + public CompletionStage replace(ViewRequest request) { + thread(); var tracks = new HashMap(); + MapDataCache.get(key(request.world().dimensionId())).tracks.forEach(t -> tracks.put(t.id, t)); + var perRail = new HashMap(); + java.util.stream.Stream.concat(request.spans().stream(), request.spanStyles().stream().map(SpanStyle::span)) + .forEach(s -> perRail.merge(s.rail().railId(), 1, Integer::sum)); + if (perRail.values().stream().anyMatch(n -> n > 256)) return CompletableFuture.completedFuture(Status.INVALID); + if (java.util.stream.Stream.concat(request.spans().stream(), request.spanStyles().stream().map(SpanStyle::span)) + .anyMatch(s -> !tracks.containsKey(s.rail().railId()) || tracks.get(s.rail().railId()).curve == null)) + return CompletableFuture.completedFuture(Status.UNAVAILABLE); + return delegate.replace(request); + } + public void clear() { thread(); delegate.clear(); } + public void close() { thread(); delegate.close(); } + }; + } + public BoardSubscription watchStation(WorldRef world, String stationId, Consumer listener) { + thread(); stationId = Long.toUnsignedString(Long.parseUnsignedLong(stationId, 16), 16); Objects.requireNonNull(listener); + var key = new Key(key(world.dimensionId()), stationId); + if (!watches.containsKey(key) && watches.size() >= 6) throw new IllegalStateException("Station interest capacity reached"); + var watch = new Watch(world, key, listener); watches.computeIfAbsent(key, ignored -> new ArrayList<>()).add(watch); + if (catalog(world).status() == Status.STALE) watch.value = empty(world, key, 0, Status.STALE); + else if (!MTRNetworkClient.canSendOverlay()) watch.value = empty(world, key, 0, Status.UNSUPPORTED); + else queries.computeIfAbsent(key, ignored -> new Query(++generation)); + return watch; + } + private static Board empty(WorldRef world, Key key, long gen, Status status) { return new Board(world, key.station, gen, status, 0, 0, List.of(), List.of()); } + private final class Watch implements BoardSubscription { + final WorldRef world; final Key key; final Consumer listener; Board value; long received; boolean closed; + Watch(WorldRef world, Key key, Consumer listener) { this.world = world; this.key = key; this.listener = listener; value = empty(world, key, 0, Status.LOADING); } + public Board latest() { thread(); return value; } + void accept(Board board) { if (closed) return; value = board; received = System.nanoTime(); try { listener.accept(board); } catch (RuntimeException e) { MTRMap.LOGGER.warn("[MTRMap] Station API listener failed", e); } } + public void close() { + thread(); if (closed) return; closed = true; + var list = watches.get(key); if (list != null) { list.remove(this); if (list.isEmpty()) { watches.remove(key); cancel(key); } } + } + } + private void cancel(Key key) { + Query query = queries.remove(key); + if (!clearing && query != null && MTRNetworkClient.canSendOverlay()) MTRNetworkClient.sendToServer(new OverlayRequest(query.generation, key.dimension, key.station, false)); + } + public void tick() { + thread(); OverlayUi.tick(); + if (Minecraft.getInstance().getConnection() == null) return; + long now = System.nanoTime(); int sent = 0; + for (var entry : List.copyOf(queries.entrySet())) { + var query = entry.getValue(); var key = entry.getKey(); + if (query.transfer != null && now - query.transfer.createdNanos > 30_000_000_000L) query.transfer = null; + if (!key.station.isEmpty()) for (var watch : List.copyOf(watches.getOrDefault(key, List.of()))) { + if (watch.value.status() == Status.LOADING && now - query.started > 15_000_000_000L) watch.accept(empty(watch.world, key, query.generation, Status.ERROR)); + else if (watch.received > 0 && now - watch.received > 5_000_000_000L && watch.value.status() != Status.STALE) + watch.accept(new Board(watch.world, key.station, query.generation, Status.STALE, watch.value.sampledAt(), watch.value.serverNow(), watch.value.services(), watch.value.arrivals())); + } + if (queries.get(key) == query && sent < 2 && MTRNetworkClient.canSendOverlay() && now - query.requestAt > 1_000_000_000L) { + sent++; query.requestAt = now; MTRNetworkClient.sendToServer(new OverlayRequest(query.generation, key.dimension, key.station, true)); + } + } + for (var c : List.copyOf(catalogs.values())) if (catalog(c.world()).status() == Status.STALE) + queries.computeIfAbsent(new Key(key(c.world().dimensionId()), ""), ignored -> new Query(++generation)); + views.invalidate(catalogs.values().stream().map(c -> catalog(c.world())).toList()); + } + public void chunk(OverlayChunk message) { + thread(); var key = new Key(message.dimension(), message.station()); var query = queries.get(key); + if (query == null || query.generation != message.generation() || message.sequence() <= query.sequence) return; + try { + if (query.transfer == null || query.transferSequence != message.sequence()) { + if (message.sequence() < query.transferSequence) return; + if (message.total() > OverlayCodec.MAX_BYTES) throw new IllegalArgumentException("Overlay response too large"); + query.transferSequence = message.sequence(); query.transfer = new OverlayAssembly(message.count(), message.total()); + } + if (!query.transfer.add(message.index(), message.count(), message.total(), message.bytes())) return; + byte[] bytes = query.transfer.assemble(); query.transfer = null; + query.sequence = message.sequence(); + try { decoder.execute(() -> { + try { + Record decoded = key.station.isEmpty() ? OverlayCodec.decode(bytes, Catalog.class) : OverlayCodec.decode(bytes, Board.class); + Minecraft.getInstance().execute(() -> install(key, query, message.sequence(), decoded)); + } catch (Exception e) { MTRMap.LOGGER.warn("[MTRMap] Invalid overlay response", e); } + }); } catch (RejectedExecutionException busy) { queries.put(key, new Query(++generation)); } + } catch (Exception e) { query.transfer = null; MTRMap.LOGGER.warn("[MTRMap] Invalid overlay response", e); } + } + private void install(Key key, Query query, long sequence, Record decoded) { + thread(); + if (queries.get(key) != query || query.sequence != sequence) return; + if (decoded instanceof Catalog catalog) { + if (!key(catalog.world().dimensionId()).equals(key.dimension)) return; + catalogs.put(key.dimension, catalog); queries.remove(key); + } else { + var board = (Board) decoded; + if (!key(board.world().dimensionId()).equals(key.dimension) || !board.stationId().equals(key.station)) return; + for (var watch : List.copyOf(watches.getOrDefault(key, List.of()))) if (watch.world.equals(board.world())) + watch.accept(new Board(board.world(), board.stationId(), query.generation, board.status(), board.sampledAt(), board.serverNow(), board.services(), board.arrivals())); + } + } + public void clear() { + thread(); clearing = true; + try { OverlayUi.clear(); } finally { clearing = false; } + queries.clear(); decoder.getQueue().clear(); catalogs.clear(); generation++; views.clear(); ViewRenderer.clear(); + watches.values().forEach(list -> list.forEach(w -> { w.closed = true; w.value = empty(w.world, w.key, 0, Status.CLOSED); })); watches.clear(); + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java b/src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java new file mode 100644 index 0000000..eca8420 --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/CoreOverlayAdapter.java @@ -0,0 +1,112 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.api.MtrMapApi.*; +import org.mtr.core.data.Position; +import org.mtr.core.data.TransportMode; +import org.mtr.core.operation.ArrivalsRequest; +import org.mtr.core.simulation.Simulator; +import org.mtr.libraries.it.unimi.dsi.fastutil.longs.LongImmutableList; +import java.util.*; + +/** Simulator-thread reads only. No independent timetable, routing or motion simulation. */ +public final class CoreOverlayAdapter { + private CoreOverlayAdapter() {} + private static String id(long id) { return Long.toUnsignedString(id, 16); } + private static Point point(Position p) { return new Point(p.getX(), p.getY(), p.getZ()); } + public static Catalog catalog(Simulator simulator, WorldRef world, long base) { + var stations = new ArrayList(); + var platforms = new ArrayList(); + var platformStations = new HashMap(); + for (var station : simulator.stations) { + var ids = new ArrayList(); + for (var platform : station.savedRails) { + ids.add(id(platform.getId())); platformStations.put(platform.getId(), id(station.getId())); + platforms.add(new Platform(id(platform.getId()), id(station.getId()), platform.getName(), point(platform.getMidPosition()))); + } + stations.add(new Station(id(station.getId()), station.getName(), point(station.getCenter()), ids)); + } + var routes = new ArrayList(); + for (var route : simulator.routes) { + if (route.getTransportMode() != TransportMode.TRAIN) continue; + var stops = new ArrayList(); + for (var stop : route.getRoutePlatforms()) { + var platform = stop.getPlatform(); + stops.add(new Stop(stops.size(), platform == null ? "" : id(platform.getId()), + platform == null ? "" : platformStations.getOrDefault(platform.getId(), ""), stop.getCustomDestination())); + } + routes.add(new io.github.teamcreating.mtrmap.api.MtrMapApi.Route(id(route.getId()), route.getName(), route.getRouteNumber(), + route.getColor(), route.getRouteType().name(), stops, paths(route, stops))); + } + long revision = base; + for (Object value : List.of(stations, platforms, routes)) revision = (revision ^ value.hashCode()) * 0x100000001b3L; + return new Catalog(world, revision, base, Status.READY, stations, platforms, routes); + } + /** Match the complete native stop sequence, never an intersection of route/platform memberships. */ + private static List paths(org.mtr.core.data.Route route, List stops) { + if (stops.size() < 2 || stops.stream().anyMatch(s -> s.platformId().isEmpty())) return List.of(); + var variants = new ArrayList(); + for (var depot : route.depots) { + var path = depot.getPath(); + if (path.size() > 20000 || variants.size() >= 128) break; + var indices = new ArrayList(); + for (int i = 0; i < path.size(); i++) { + var p = path.get(i); + if (p.getSavedRailBaseId() != 0 && (indices.isEmpty() + || path.get(indices.get(indices.size() - 1)).getSavedRailBaseId() != p.getSavedRailBaseId() + || path.get(indices.get(indices.size() - 1)).getStopIndex() != p.getStopIndex())) indices.add(i); + } + for (int start = 0; start + stops.size() <= indices.size() && variants.size() < 128; start++) { + boolean matches = true; + for (int j = 0; j < stops.size(); j++) + if (!id(path.get(indices.get(start + j)).getSavedRailBaseId()).equals(stops.get(j).platformId())) { matches = false; break; } + if (!matches) continue; + var legs = new ArrayList(); + for (int j = 0; j + 1 < stops.size(); j++) { + var rails = new ArrayList(); + int first = indices.get(start + j), last = indices.get(start + j + 1); + for (int k = first; k <= last; k++) { + var p = path.get(k); var rail = p.getRail(); + if (rail == null || !rail.isValid() || rail.railMath == null) { matches = false; break; } + double from = k == first ? 0.5 : 0, to = k == last ? 0.5 : 1; + rails.add(new RailSpan(rail.getHexId(), p.reversePositions ? 1 - from : from, p.reversePositions ? 1 - to : to)); + } + if (!matches) break; + legs.add(new Leg(j, j + 1, rails)); + } + if (matches) variants.add(new PathVariant(id(depot.getId()) + ":" + start, legs)); + } + } + return List.copyOf(variants); + } + public static Board board(Simulator simulator, WorldRef world, String stationId, long generation) { + long now = simulator.getCurrentMillis(); + var station = simulator.stationIdMap.get(Long.parseUnsignedLong(stationId, 16)); + if (station == null) return new Board(world, stationId, generation, Status.NOT_FOUND, now, now, List.of(), List.of()); + if (station.savedRails.size() > 128) return new Board(world, stationId, generation, Status.LIMITED, now, now, List.of(), List.of()); + long[] ids = station.savedRails.stream().mapToLong(p -> p.getId()).toArray(); + var services = new LinkedHashMap(); + for (var platform : station.savedRails) for (var route : platform.routes) { + if (route.getTransportMode() == TransportMode.TRAIN) services.put(id(route.getId()) + ":" + id(platform.getId()), + new Service(id(route.getId()), id(platform.getId()), route.getName(), route.getColor(), platform.getName())); + } + var entries = new ArrayList(); + boolean complete = false; + for (int count : new int[]{10, 40, 160}) { + var response = new ArrivalsRequest(new LongImmutableList(ids), count, 4097).getArrivals(simulator); + now = response.getCurrentTime(); entries.clear(); + var seen = new HashSet(); + var groups = new HashSet(); + for (var a : response.getArrivals()) { + String key = id(a.getRouteId()) + ":" + id(a.getPlatformId()) + ":" + a.getDestination() + ":" + a.getCircularState(); + if (a.getDeparture() < now || !groups.add(key)) continue; + seen.add(id(a.getRouteId()) + ":" + id(a.getPlatformId())); + entries.add(new Arrival(id(a.getRouteId()), a.getRouteName(), a.getRouteColor(), id(a.getPlatformId()), a.getPlatformName(), + a.getDestination(), a.getArrival(), a.getDeparture(), a.getDeviation(), a.getRealtime(), a.getIsTerminating())); + } + complete = seen.containsAll(services.keySet()) && response.getArrivals().size() < 4097; + if (complete || entries.size() >= 4096) break; + } + return new Board(world, stationId, generation, complete ? Status.READY : Status.LIMITED, now, now, + new ArrayList<>(services.values()), entries); + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java new file mode 100644 index 0000000..e0e29dc --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayAssembly.java @@ -0,0 +1,25 @@ +package io.github.teamcreating.mtrmap.extension; +import io.github.teamcreating.mtrmap.network.OverlayChunk; +import java.util.Arrays; +public final class OverlayAssembly { + public final long createdNanos = System.nanoTime(); + private final byte[][] parts; private final int total; private int received; + public OverlayAssembly(int count, int total) { + if (total <= 0 || total > OverlayCodec.MAX_BYTES || count != (total + OverlayChunk.CHUNK - 1) / OverlayChunk.CHUNK) + throw new IllegalArgumentException("Invalid overlay transfer size"); + this.total = total; parts = new byte[count][]; + } + public boolean add(int index, int count, int total, byte[] bytes) { + if (count != parts.length || total != this.total || index < 0 || index >= count || bytes.length != Math.min(OverlayChunk.CHUNK, total - index * OverlayChunk.CHUNK)) + throw new IllegalArgumentException("Invalid overlay part"); + if (parts[index] == null) { parts[index] = bytes.clone(); received++; } + else if (!Arrays.equals(parts[index], bytes)) throw new IllegalArgumentException("Conflicting overlay part"); + return received == parts.length; + } + public byte[] assemble() { + if (received != parts.length) throw new IllegalStateException("Incomplete overlay transfer"); + byte[] result = new byte[total]; int offset = 0; + for (var part : parts) { System.arraycopy(part, 0, result, offset, part.length); offset += part.length; } + return result; + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java new file mode 100644 index 0000000..d6d3bb5 --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayCodec.java @@ -0,0 +1,88 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.api.MtrMapApi; +import java.io.*; +import java.lang.reflect.*; +import java.util.*; + +/** Schema-directed record codec: wire data never chooses a Java class. Bounded at every level. */ +public final class OverlayCodec { + public static final int MAX_BYTES = 8 * 1024 * 1024; + private OverlayCodec() {} + public static byte[] encode(Record value) throws IOException { + var bytes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(new OutputStream() { + private void capacity(int n) throws IOException { if (n > MAX_BYTES - bytes.size()) throw new IOException("Overlay response too large"); } + @Override public void write(int value) throws IOException { capacity(1); bytes.write(value); } + @Override public void write(byte[] data, int offset, int length) throws IOException { capacity(length); bytes.write(data, offset, length); } + })) { write(out, value, value.getClass(), 0); } + if (bytes.size() > MAX_BYTES) throw new IOException("Overlay response too large"); + return bytes.toByteArray(); + } + public static T decode(byte[] bytes, Class type) throws IOException { + if (bytes.length > MAX_BYTES) throw new IOException("Overlay response too large"); + try (var in = new DataInputStream(new ByteArrayInputStream(bytes))) { + Object value = read(in, type, 0, new int[]{200000}); + if (in.available() != 0) throw new IOException("Trailing overlay bytes"); + return type.cast(value); + } + } + private static void check(int depth) throws IOException { if (depth > 20) throw new IOException("Overlay nesting too deep"); } + private static void write(DataOutputStream out, Object value, Type type, int depth) throws IOException { + check(depth); + if (type instanceof ParameterizedType p) { + Type[] args = p.getActualTypeArguments(); + if (p.getRawType() == Optional.class) { var v = (Optional) value; out.writeBoolean(v.isPresent()); if (v.isPresent()) write(out, v.get(), args[0], depth + 1); } + else if (p.getRawType() == Map.class) { + var map = (Map) value; count(out, map.size()); + for (var entry : map.entrySet()) { write(out, entry.getKey(), args[0], depth + 1); write(out, entry.getValue(), args[1], depth + 1); } + } else { + var list = (Collection) value; count(out, list.size()); for (Object item : list) write(out, item, args[0], depth + 1); + } + return; + } + Class c = (Class) type; + if (c == String.class) { if (((String) value).length() > 8192) throw new IOException("Text exceeds capacity"); out.writeUTF((String) value); } + else if (c == int.class || c == Integer.class) out.writeInt((Integer) value); + else if (c == long.class) out.writeLong((Long) value); + else if (c == double.class) out.writeDouble((Double) value); + else if (c == boolean.class) out.writeBoolean((Boolean) value); + else if (c == UUID.class) { out.writeLong(((UUID) value).getMostSignificantBits()); out.writeLong(((UUID) value).getLeastSignificantBits()); } + else if (c.isEnum()) out.writeUTF(((Enum) value).name()); + else if (c.isRecord() && c.getEnclosingClass() == MtrMapApi.class) { + try { for (var component : c.getRecordComponents()) write(out, component.getAccessor().invoke(value), component.getGenericType(), depth + 1); } + catch (ReflectiveOperationException e) { throw new IOException(e); } + } else throw new IOException("Unsupported schema " + c); + } + private static void count(DataOutputStream out, int count) throws IOException { if (count > 20000) throw new IOException("Collection exceeds capacity"); out.writeInt(count); } + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object read(DataInputStream in, Type type, int depth, int[] remaining) throws IOException { + check(depth); if (--remaining[0] < 0) throw new IOException("Overlay object budget exceeded"); + if (type instanceof ParameterizedType p) { + Type[] args = p.getActualTypeArguments(); + if (p.getRawType() == Optional.class) return in.readBoolean() ? Optional.of(read(in, args[0], depth + 1, remaining)) : Optional.empty(); + int n = in.readInt(); if (n < 0 || n > 20000 || n > remaining[0]) throw new IOException("Invalid collection count"); + if (p.getRawType() == Map.class) { + var map = new LinkedHashMap<>(); + for (int i = 0; i < n; i++) { Object key = read(in, args[0], depth + 1, remaining); if (map.put(key, read(in, args[1], depth + 1, remaining)) != null) throw new IOException("Duplicate map key"); } + return Map.copyOf(map); + } + var list = new ArrayList<>(); for (int i = 0; i < n; i++) list.add(read(in, args[0], depth + 1, remaining)); + return p.getRawType() == Set.class ? Set.copyOf(list) : List.copyOf(list); + } + Class c = (Class) type; + if (c == String.class) { String text = in.readUTF(); if (text.length() > 8192) throw new IOException("Text exceeds capacity"); return text; } + if (c == int.class || c == Integer.class) return in.readInt(); + if (c == long.class) return in.readLong(); + if (c == double.class) return in.readDouble(); + if (c == boolean.class) return in.readBoolean(); + if (c == UUID.class) return new UUID(in.readLong(), in.readLong()); + try { + if (c.isEnum()) return Enum.valueOf((Class) c, in.readUTF()); + if (!c.isRecord() || c.getEnclosingClass() != MtrMapApi.class) throw new IOException("Unsupported schema"); + var components = c.getRecordComponents(); var values = new Object[components.length]; var types = new Class[values.length]; + for (int i = 0; i < values.length; i++) { values[i] = read(in, components[i].getGenericType(), depth + 1, remaining); types[i] = components[i].getType(); } + return c.getConstructor(types).newInstance(values); + } catch (ReflectiveOperationException | IllegalArgumentException e) { throw new IOException("Invalid overlay data", e); } + } +} diff --git a/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java new file mode 100644 index 0000000..fed894e --- /dev/null +++ b/src/main/java/io/github/teamcreating/mtrmap/extension/OverlayUi.java @@ -0,0 +1,293 @@ +package io.github.teamcreating.mtrmap.extension; + +import io.github.teamcreating.mtrmap.api.MtrMapApi.*; +import io.github.teamcreating.mtrmap.config.MTRMapConfig; +import io.github.teamcreating.mtrmap.config.PidsStyle; +import io.github.teamcreating.mtrmap.mapdata.*; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import org.lwjgl.glfw.GLFW; +import java.util.*; +import java.util.function.DoubleBinaryOperator; + +/** Classic station tooltip with an optional native arrival board. Host retains the camera. */ +public final class OverlayUi { + private record Button(int x, int y, int w, int h, Component label, boolean enabled, Runnable action) { + boolean contains(double mx, double my) { return mx >= x && my >= y && mx < x + w && my < y + h; } + } + private record Target(double x, double y, String station, MapLandmark marker) {} + private static final List