Skip to content

About

Dynamic Waters (Forge 1.20.1) server-side addon: builds mountain rivers off the server thread and keeps them on disk, and stops structures near rivers being deleted -- nudging them to the driest nearby spot instead. All claims measured over 22,201 chunks.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Dynamic Waters Async Rivers

Dynamic Waters 11.1.x のサーバ専用アドオンです。本体を置き換えるものではなく、両方が要ります。 やることは2つあります。

  1. 山岳河川づくりをサーバスレッドから外し、組んだ結果をディスクに残す(1.x〜2.x)
  2. 川のそばの構造物を消すのをやめ、代わりに水の少ない場所へ横へずらす(3.0.0)
  • Minecraft 1.20.1 / Forge 47.x / Java 17
  • 依存: dynamicwaters [11.1,12)
  • ライセンス: MIT

何が起きていたか

Dynamic Waters は ChunkWatchEvent.Watch の中で、見えたチャンクの周り±2リージョン (1リージョン=256ブロック四方)の山岳河川を同期で引き直します。参加時は視界ぶんの チャンクが一斉に発火するので、約36リージョンをプレイヤーを配置する前に作りきることに なります。しかも記録はメモリ上にしか無いので再起動のたびに冷え、最初に入った1人が 全員ぶんを肩代わりします。その人からは「キャラが落下し続け、やがてタイムアウトする」 ように見えます。

実測(専用サーバ・再起動直後に参加・RCON の応答遅れで測定):

構成 サーバスレッドが塞がった時間
素のまま(記録が冷えている) 231,451 ms
素のまま(記録が温まっている) 7,909 ms
enableMountainRivers = false(機能を捨てる) 0 ms

このMODがやること

どこ 何をするか
DynamicWaters$ModEvents.onChunkWatch mixin で中止し、同じ仕事をワーカースレッドで行う。できたぶんからパケットを送る
DynamicWaters$ModEvents.onPlayerLogin 参加のたびに出る互換案内のチャットだけを落とす(下記・2.2.0)
HydrologyManager.getRiverFlow cachedOnly を true に固定する。専用サーバでは false を渡す作りで、毎tick その場で作りにいっていた
起動時(無人のあいだ) ワールドボーダーの内側を中心から順に先読みして記録を埋める。人が居る間は止まる
全体 組んだリージョンをワールド配下のファイルに残し、次の起動から読み直す

⚠ わざと触っていないもの: calculateRiverCarving。同じ重い計算に入りますが 地形を彫る側なので、短絡すると生成される地形が変わります。

⚠ isNearRiver / isTooCloseToRiver は 2.3.0 から短絡しています(構造物の削除を止めるため)。 どちらの唯一の呼び出し元も StructureMixin で、彫る経路からは呼ばれないことを dynamicwaters-11.1.2.jar を読んで確かめてあります。ずらし先を探すときだけ、 スレッドローカルの旗を立てて本当の答えを返させています。

ディスク保存

<ワールド>/data/asyncrivers/v1/<次元>/r.<x>.<z>.bin

1リージョン1ファイル。各ファイルの先頭に印を持たせ、合わないものは読み飛ばして 作り直します。印に混ぜているのは3つです。

  • ワールドの seed
  • Dynamic Waters の版 — 本体のフィールドをそのまま直列化しているので、上流の構造が 変われば書式も変わります
  • その config ファイルの中身 — 川幅や山岳河川の有無が生成物を変えます。 ⚠ わざと粗くしてあり、関係ない設定を触っても作り直しになります。作り直しは時間を 失うだけですが、地形と食い違う経路を返すのは正しさを失うので、時間のほうを払います

書き込みは一時ファイルからのアトミック移動なので、落ちても「古いファイル」か「無い」の どちらかになり、半端なものは残りません。読めないファイルは「無い」と同じ扱いです。

⚠ 一番効くのはここです。 ディスクから戻した結果は Dynamic Waters 本体のメモリ キャッシュへ入れ直します。そのキャッシュは地形を彫る処理(ワールド生成スレッド)も 読むので、参加が速くなるだけでなくチャンク生成そのものが速くなります。

誰が作ったリージョンでも書き残す(2.1.0)

2.0.0 まで、ディスクへ書いていたのはこのMODが自分で頼んだぶんだけでした。 起動時の先読みと、プレイヤーが歩いて入った場所です。

地形を彫る処理はそこを通りません。 calculateRiverCarving はワールド生成スレッドから Dynamic Waters 本体の getMountainRegionPaths を直接呼びます。無人での事前生成では それがほぼ全てで、作られたリージョンは 2,048 件のメモリキャッシュに載っては追い出され、 一度もディスクに届かないまま消えていました。次の起動でまた作り直しです。

2.1.0 で getMountainRegionPaths の戻りに観測者を1つ置きました。 全ての呼び出しがここを通るので、彫り込みも先読みも参加時も毎tickの水流も、 作られたものは残ります。

⚠ この観測者は純粋な観測者であり、そのままにしておく必要があります。 戻り値を読んで書き残すだけで、引数を取らず、打ち切りもせず、何も返しません。 だから「そのリージョンを作るかどうか」の判断に関与できません—— 彫り込みは cachedOnly = false で頼むのが正しく、そこを短絡すると彫られる地形が変わります。

⚠ 費用について。この観測者はキャッシュに当たった呼び出しでも走るので、 やることが無いときは限りなく安くある必要があります。「もうディスクに在る」の判定は メモリ上の集合で答えます。ファイルを触るのは、そのリージョンを初めて見たときだけです。

参加のたびに出る互換案内を落とす(2.2.0)

onPlayerLogin は3つのことをします。前の2つは残し、3つ目だけを落とします。

  1. SENT_REGIONS にその人の枠を作る — 残す
  2. シードと海面高さのパケットを送る — 残す(これが無いとクライアント側の川が正しく描かれません)
  3. 相手MODが入っていれば互換案内をチャットへ流す — 落とす

当部は Tectonic を入れているので、毎回2行出ていました。

[Dynamic Waters] Compatibility with mod Tectonic is still beta.
[Dynamic Waters] Tectonic underground rivers were automatically disabled for better performance.

⚠ 設定では消せません。 この4件(Tectonic 2・Lithosphere 1・Larion 1)は ModList.isLoaded(...) だけで分岐しており、その経路に config の読み出しが1つもありません (dynamicwaters-11.1.2.jar を逆アセンブルして確認)。

⚠ 処理ごと中止してはいけません。 2 が同じメソッドの中で先に走っているためです。 だから @Redirect で文字を送る呼び出しだけを無効にしています。

⚠ 書いてある内容は本当のままです。 挙動は何も変えておらず、知らせ方だけを変えています。 地下河川を自動で切る設定(disableTectonicUndergroundRivers)は入のままです。

⚠ require = 4 で件数を固定しています。 上流が案内を増減させたら読み込み時に大きく失敗 します。黙って宣伝が戻るより、止まって気づくほうを選びました。版の範囲を縛っているのは これを見落とさないためです。

川に載った構造物(3.0.0)

Dynamic Waters は川のそばに出来た構造物を丸ごと消します(StructureMixin.cancelStructureInRiver)。 種 1704108117075065668 を半径1000(22,201チャンク)で焼いて実測した代償:

設定 構造物の起点 村
素のまま(消す) 105 2
keepStructuresNearRivers = true(消さない) 144 5

同じワールドで山岳河川が占める面積は 1.19% です。消す範囲は構造物自身の半径ぶん広がるので、 いちばん大きい村がいちばん失われます。⚠ 縦方向が ±70 ブロックなのも効いていて、 地下60ブロックの小屋が、触れようのない川に消されていました(alexscaves:underground_cabin が10件)。

⚠ 消さなくても構造物は守られます。 NoiseChunkGeneratorMixin.carveAndFillRiver が startsForStructure で全ての起点を集め、柱ごとに BitSet[256] を作って彫るブロック書き込みを 1つずつ止めています。つまり削除は保護ではなく、ただの除去でした。

消す代わりに、横へずらす

⚠ どこへ動かしても良くならなければ、その場に残します。消すことはありません。

判定は4つ試して、3つを実測で否定しました。

版 何を聞いたか なぜ駄目だったか
2.4 isTooCloseToRiver に箱を渡す ⚠ この関数は箱の9点しか見ない({minX, maxX, 中点} の 3×3)。80×120 の村では、水が8.0%の柱にあっても9点とも陸に当たる
2.6 川バイオームの判定を足す ⚠ 対象の村2つは alpine_highlands / forested_highlands が 651/651・506/506。川バイオームは1セルも無い。水は彫られたもので、バイオームでは見えない
2.8 calculateRiverCarving(彫りの実結果) ⚠ 構造物を置く時点では RIVER_BED_NOISE が null で NPE
3.0 DW の川の形+当部のしきい値を格子で走査 —

3.0.0 の判定

濡れている = 中心線までの距離 < 川幅 × factor
            かつ |その柱の地面の高さ − 川の高さ| <= heightDelta

⚠ 高さを見るのが要点です。 evaluateRiverContext(double, double) は引数に Y を取りません。 高さを見ないと、谷底の川が真上の山頂を「川の上」にします。実測: 地面が Y 244 の山岳村で、 高さ抜きの判定は 58柱を濡れと数えましたが、焼き上がった世界に水があるのは 9柱(1.2%)。 残りは 181ブロック下の川でした。平地の川は海面と、山岳河川は MountainRiverContext.bestY と比べます。

⚠ DW 自身の isNearRiver(距離 < 幅 + margin + 10)は使いません。 あれは構造物を消すための緩衝帯で、置く判断には広すぎます。同じ範囲を同時に測ると、 当部の判定が中央 7.9% のところ DW の判定は 38.5% — 約5倍を川と見なしていました。

動かし方の3つの制約

制約 なぜ
ゼロを要求しない 最も濡れない位置を選ぶ。ゼロを条件にすると、周りが15%濡れているだけで 80×120 の箱は全候補でどこかが当たり、村が1件も動かなかった
⚠ バニラの参照窓(8チャンク)を候補ごとに直接見る スカラーの予算(128 − 半径 − 16)で近似していたが、判定時の箱と最終的な箱が食い違い、使えるはずの48ブロックを捨てていた。窓を超えると村の一部が生成されずに欠ける
⚠⚠ 地面の高さが変わる先へは動かさない StructurePiece.move(dx, 0, dz) は水平にしか動かしません。家の Y は据え置きなので、山で横へ動かすと地面だけが上がって家が埋まります。実測: 山岳村を +32,+32 動かして地面が 247 → 252、家が5ブロック沈みました

効果(同じ22,201チャンク・実測)

ずらしの発火 30件 → 動かせた 29 / その場が最良 1
箱の下の濡れ柱(判定値) 695 → 220 / 完全に外れた 16件
⚠ 地面の制約で捨てた候補 411件(=水は減るが家が沈む位置)

村の中の実際の水ブロック(焼き上がった世界を柱ごとに数えたもの):

村 素のまま 3.0.0
ctov:large/village_taiga ⚠ 消滅 4.6%(30/651)
terralith:fortified_village ⚠ 消滅 0.2%(1/506)
ctov:large/village_mountain 1.2%(9/748) 1.2%(動かさない)

設定

鍵 既定 何
keepStructuresNearRivers true 削除をやめる。false で素の挙動
nudgeStructuresOffRivers true ずらす
riverChannelFactor 1.0 川幅の何倍までを「川の中」とするか
riverMaxHeightDelta 16 地面と川の高さがこれ以上離れていたら無視する
maxGroundShift 2 移動で地面がこれ以上動くなら、その候補を捨てる
referenceChunks 8 ⚠ バニラの参照窓。上げると構造物が欠けます
riverProbeStep 8 走査の刻み(ブロック)
riverProbeCompare true DW の判定も同時に測ってログに並べる

⚠ ログに理由が出ます。 moved … 39 -> 27 wet under it, 28 candidates fit, 12 refused by ground のように、動いたか/留まったか/なぜかが1行で読めます。 黙って諦める作りだったとき、「効いていない」と「移す先が無かった」の区別がつかず、 効果のない変更を2回重ねました。

先読み(無人時の下ごしらえ)

ワールドボーダーの内側を、中心から外へリングの順に埋めます。人が入ってきたら止まり、 全員抜けたら再開します。

⚠ ボーダーが設定されていないワールドでは行いません。 バニラの既定は直径 59,999,968 で、 半径 117,188 リージョン=549億リージョンになります。半径64リージョンを超えたら、理由を 1行出して先読みを見送ります。遅いのではなく、一度も終わらないためです。

⚠ Dynamic Waters が一度に持てるリージョン数(2,048)で打ち切ります。 それを超えて 舐めると載せた端から追い出され、先読みの意味が消えます。中心から並べているので、残るのは スポーンに近い側です。打ち切った外側も、必要になった時点でディスクから読めば済みます。

版で縛っている理由

mods.toml は dynamicwaters を [11.1,12) に縛っています。本MODは onChunkWatch を 中止するので、Dynamic Waters 自身の SENT_REGIONS が空のままになります。11.1.x では onChunkWatch と onPlayerLogout しか読まないので無害ですが、上げるときはそこを 読み直してください。

測り方(再現手順)

  1. サーバを再起動する(記録が温まっていると差が出ない)
  2. 起動ログの Async Rivers: pre-warm finished を待つ(無人時のみ進む)
  3. RCON へ1秒おきに list を撃ち、応答が返るまでの時間を記録する (RCON はサーバスレッド上で実行されるので、塞がった時間がそのまま出る)
  4. 人が参加する

山岳河川が残っているかの確認は /locate_river_terrain です。この命令はキャッシュ参照のみ なので、「見つかる=実際に組んで記録に入れた」の証拠になります。

⚠ この命令は成功しかログに残しません。 A/B を取るときは両方の出力を RCON の戻り値で 控えてください。片側が残らないと、後から撃ったことすら確認できません。

残っている課題

  • メモリキャッシュの上限(2,048)そのものは変えられません。 ボーダーが広いワールドでは 外側がメモリから落ちます。ただし 2.1.0 以降は落ちた先がディスクに在るので、 代償は「作り直し」ではなく「読み直し」です。上限で打ち切るのは無駄の停止であって 解決ではない、という位置づけは変わりません
  • 先読みの範囲を事前生成の前線に合わせる案。いまはボーダー全体を対象にしています。 ⚠ 2.1.0 で彫り込みぶんが残るようになったので、事前生成を伴う運用では先読みの重みが 下がりました(Chunky が焼いた場所は、先読みを待たずに残る)。この案を検討するなら、 まず「先読みがまだ何を守っているのか」を測り直すところからになります

由来

ディスク保存の着想と形は、この構成を使っている人からの dynamicwaters_async_backport (MIT)によります。ファイル形式は本MOD独自のもので、互換性はありません。

3.1.1 — 先読みが終わるまで参加を断る(2026-08-21)

なぜ

「人が入ったら止まり、出たら再開する」は先読みには既に入っていた (MountainRiverScheduler の getPlayerCount() > 0 で5秒ごとに見る)。 ⚠ 足りなかったのは、止まっている間に入ってきた人の扱い。 先読みが終わる前に入ると、その人の周りの山岳河川はその場で作られる—— サーバスレッドは塞がらないが地形が来るのを待つことになり、 症状は「入れたのに地形が来ない」。⚠ 入った人には、それが何なのか分からない。

⚠ 掘る処理(calculateRiverCarving)は相変わらず止められない(止めると地形が変わる)。 そちらはワールドボーダーを生成済みの内側に置くことで防ぎ(BorderKeeper 1.2.0)、 起きたときは記録に1行出す(下記)。

何をするか

追加 中身
LoginGateMixin ServerLoginPacketListenerImpl#handleHello の先頭で断る。⚠ 認証より前なので速いし、手元から素の接続で確かめられる
MountainRiverScheduler.joinShouldWait() ⚠ 残りが 20 リージョンを超えているときだけ真。記録が温まっている起動は1秒で終わるので、そこで断ると邪魔なだけ
断りの記録 ⚠ 断ったことを残す。残らないと、部員が弾かれ続けても運用側から見えない(「誰も来ない」としか見えない)
冷たいリージョンの警告 人が居るあいだに新しいリージョンを作ったら1行出す(30秒に1行へ間引き・件数つき)。⚠ 2026-08-20 に 217 秒固まったとき記録が何も出ず、jstack が要った

実測(2026-08-21・ステージングサーバ)

⚠ 冷たい先読みは 81 リージョンで約8分(09:10:51 → 09:18:5x・記録を消して計測)。 = 断る窓は実在する。

試したこと 結果
先読み中に接続(残り 81 / 81) 断られた。文面に残り数が入る。サーバ側にも 参加を断った(GateProbe / 残り 81 / 81)
残り 5 まで進んだ時点で接続 通った(Encryption Request が来た)=しきい値 20 の下では邪魔しない
素の接続(Forge の目印なし) Forge 自身が先に断る(This server has mods that require Forge…)=門まで届かない

⚠ 確かめていないこと: 「人が居るあいだに冷たいリージョンを作った」の警告は、 人が居る条件での発火を実機で見ていない(正規のクライアントが要る)。 書き出しの経路(新しいリージョンを書いたときだけ真)は 3.1.1 で返り値を足して確かめた。

About

Dynamic Waters (Forge 1.20.1) server-side addon: builds mountain rivers off the server thread and keeps them on disk, and stops structures near rivers being deleted -- nudging them to the driest nearby spot instead. All claims measured over 22,201 chunks.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages