في كل مرة يتم فيها كسر كتلة في إصدارات Minecraft Beta 1.8 إلى 1.12.2، يمكن أن تكشف الإحداثيات الدقيقة للعنصر المسقط موقع لاعب آخر .
"Randar" عبارة عن استغلال لـ Minecraft يستخدم تقليل شبكة LLL للقضاء على الحالة الداخلية لـ java.util.Random
المعاد استخدامه بشكل غير صحيح في خادم Minecraft، ثم يعمل بشكل عكسي من ذلك لتحديد موقع اللاعبين الآخرين المحملين حاليًا في العالم.
انقر هنا للتعرف على Randar في شكل مقطع فيديو على YouTube بدلاً من ذلك.
شاهد الفاصل الزمني:
شاهد المزيد من الفواصل الزمنية هنا (كملفات) أو هنا (مثل YouTube).
الهدف هو تحديد المواقع داخل اللعبة (أي الإحداثيات) للاعبين الآخرين في العالم، بغض النظر عن مدى بعدهم. نحن نلعب على 2b2t، وهو أقدم وأشهر خادم Minecraft "الفوضوي" (مما يعني عدم وجود قواعد، أي أنه لا يتم حظر اللاعبين لأي سبب). إن القيام بأشياء كهذه هو نوع من "النقطة المهمة" على هذا الخادم. على هذا الخادم، الشيء الوحيد الذي يحافظ على أمان أغراضك هو أن الخريطة ضخمة (3.6 كوادريليون مربع) ولا أحد يعرف مكانك. لذا، فهي صفقة ضخمة (صفقة كسر قواعد اللعبة) أن يكون لديك استغلال تنسيقي. (بالحديث عن، قبل Randar، كان لدينا أيضًا استغلال آخر للتنسيق على 2b2t، Nocom، من 2018 إلى 2021؛ راجع هذه المقالة هنا، موضوع HackerNews، YT)
الخطأ في كود Minecraft موجود من الإصدارات Beta 1.8 (تم إصدارها عام 2011) إلى 1.12.2 (تم إصدارها عام 2017، لكن 2b2t بقي على هذا الإصدار حتى 14 أغسطس 2023). الخطأ هو أن مثيلات مختلفة لمولد الأرقام العشوائية، java.util.Random
، يعاد استخدامها بشكل عشوائي في أجزاء مختلفة من الكود (وهي غير آمنة في البداية). على وجه التحديد، هناك إعادة استخدام RNG بين توليد التضاريس وفي إجراءات اللعبة مثل كتل التعدين.
تلخيص الاستغلال :
World.rand
العالمي على وظيفة إحداثيات القطعة، من أجل التحقق من المكان الذي يجب أن يكون فيه قصر Woodland Mansion القريب (وما إذا كان هذا الجزء على وجه الخصوص).World.rand.nextFloat()
لاختيار إحداثيات XY وZ بين 0 و1. يسجل الروبوت الطابع الزمني وقيم XYZ الدقيقة.World.rand
التي تسببت في تلك العوامات الثلاثة. بشكل عام (سنأتي بمزيد من التفاصيل لاحقًا)، فإن مراقبة مخرج واحد من RNG يمكن أن يشير ضمنًا إلى أي حالة من حوالي 16 مليون حالة داخلية محتملة لـ RNG. ومع ذلك، فقد قمنا بأخذ عينات من مخرجات RNG ليس مرة واحدة فقط بل ثلاث مرات متتالية (إحداثيات X وY وZ للعنصر المسقط)، ونعرف كيف يتم تحديث الحالة الداخلية بين كل استدعاء (عملية ضرب بسيطة ، إضافة، ثم تعديل)؛ لذلك يمكننا استخدام الطرق الشبكية لتضييق الأمر بشكل فوري إلى الاحتمال الوحيد.java.util.Random
بنفس السهولة التي يمكن بها الرجوع إلى الأمام، ومن خلال الرجوع إلى الخلف يمكننا العثور عليها في بضعة آلاف من الخطوات فقط (حتى على الخوادم المزدحمة مثل 2b2t مع العديد من اللاعبين وبالتالي ثقيلة استخدام RNG)، الذي يحدد آخر مرة تمت فيها إعادة تعيين الحالة الداخلية لـ RNG، وبالتالي موقع الجزء الأحدث الذي تم تحميله على الخادم.حتى إذا كنت تلعب على خادم تم تحديثه إلى إصدار أحدث من Minecraft، أو قام بتصحيح معالجة RNG، فإن إحداثياتك لا تزال معرضة للخطر من Randar بسبب القدرة على استغلال بيانات RNG بأثر رجعي. يستخدم بعض مشغلات Minecraft تعديلات مثل ReplayMod التي تسجل الحزم، وربما لا يزال لديهم ملفات السجل هذه. إذا كان أي شخص يستخدم مثل هذا التعديل أثناء تواجدك في قاعدتك، فربما يكون قد سجل (بدون علم) بيانات RNG التي يمكن أن تكشف عن موقعك، لأن كسر الكتل هو إجراء شائع للغاية ومن المحتمل أن يحدث في مثل هذه التسجيلات، وكل كتلة من هذا القبيل فاصل يكشف عن حالة RNG للخادم وبالتالي موقع القطعة التي تم تحميلها مؤخرًا. هذا يعني أن Randar يمثل مشكلة كبيرة جدًا: نظرًا لخطر الاستغلال بأثر رجعي، على كل خادم Minecraft، يجب اعتبار كل موقع كان نشطًا في الإصدارات Beta 1.8 إلى 1.12.2 مخترقًا، حتى لو تم تحديث الخادم منذ فترة طويلة بعد 1.12. .2 أو معالجة RNG المصححة.
إذا كنت تريد استخدام استغلال Randar بنفسك ، فانتقل إلى هنا حيث قام rebane2001 بإنشاء موقع ويب حيث يمكنك سحب ملفات ReplayMod الخاصة بك من 1.12.2 ورؤية إحداثيات اللاعبين الآخرين. يتم تشغيله من جانب العميل حتى لا تترك تسجيلاتك متصفحك. إليك مقطع فيديو يوضح كيف يبدو موقع الويب هذا أثناء العمل، ويمكنك تشغيل مثال ملف ReplayMod من هذا الفيديو عن طريق تنزيله هنا.
تم اكتشاف Randar بواسطة n0pf0x (pcm1k). تمت كتابة هذه المقالة بواسطة leijurv، مع بعض التعليقات الإضافية في النهاية كتبها n0pf0x. وكان المستغلون 0x22، وBabbaj، وTheLampGod، وleijurv، وNegative_Entropy، وrebane2001. شاهد فيديو TheLampGod هنا. شاهد فيديو HermeticLock المضحك بنسبة 100% والواقعي 0% هنا.
جدول المحتويات: انقر هنا للتعرف على الكود القابل للاستغلال بمزيد من التفاصيل، هنا للتعرف على كيفية استخدام تقليل الشبكة، هنا لترى كيف قمنا بحماية مخابئنا من Randar، هنا إذا كنت تريد فقط رؤية كود الاستغلال الكامل، هنا إذا كنت تقوم بتشغيل خادم لا يزال يعمل بإصدار بين Beta 1.8 و1.12.2 وتريد تصحيح Randar، أو هنا للحصول على تفاصيل حول ما فعله n0pf0x بشكل مختلف عنا.
رسم تخطيطي للخطأ (بصيغة PDF):
رسم تخطيطي للاستغلال (بصيغة PDF):
رسم تخطيطي لمثال عملي للاستغلال (بصيغة PDF):
تعتمد لعبة Minecraft على أرقام عشوائية طوال اللعبة. نتوقع أن يكون معظمها عشوائيًا بالفعل، مثل العشوائية المستخدمة في تفريخ الغوغاء والطقس، لكن نتوقع أن يكون بعضها قابلاً للتنبؤ به، على سبيل المثال نتوقع أن تولد نفس بذرة العالم في نفس الموقع نفس التضاريس. في عام 2011، عندما أضاف Notch الهياكل إلى اللعبة لأول مرة أثناء إصدار Beta 1.8، أعاد عن طريق الخطأ استخدام RNG الذي من المفترض أنه لا يمكن التنبؤ به من أجل وضع القرى في العالم. منذ ذلك الحين، حتى 1.13، تسبب هذا الكود غير المتقن في تأثير الجيل العالمي على جميع الأحداث العشوائية الأخرى في اللعبة تقريبًا. استغرق الأمر حتى مايو 2018 تقريبًا، حتى اكتشف جهاز Earthcomputer والأصدقاء هذا الخطأ، وأدركوا أن أحمال القطع تؤثر على RNG للعبة بطريقة يمكن ملاحظتها، راجع هذا الشرح. ومع ذلك، لم يدركوا، أو لم يكشفوا علنًا أبدًا، أنه يمكنك أيضًا القيام بذلك بشكل عكسي، وتحديد أحدث قطعة تم تحميلها من مراقبة RNG. تم هذا الاكتشاف، Randar، بواسطة n0pf0x (المعروف أيضًا باسم pcm1k) في 7 أكتوبر 2022. وقد نشر وصفًا قصيرًا مشفرًا للاستغلال على Pastebin بعد حوالي أسبوعين، لإثبات أنه اكتشفه بعد ذلك. لقد استخدم هذا الاستغلال في الغالب على 9b9t، وكمية صغيرة نسبيًا فقط على 2b2t والخوادم الأخرى. في 2b2t، حدد n0p مواقع مختلفة واستكشفها، ووصل في النهاية إلى موقع مخبأ Gringotts. تم رصده بواسطة rebane2001، وكان صامتًا في البداية بشأن كيفية عثوره على الموقع. ومع ذلك، بعد حوالي شهر، بدأ محادثة مع SpawnMasons حول هذا الموضوع. كشف n0p أنه استخدم برمجيات إكسبلويت قوية وقرر مشاركة الشرح معنا في أبريل 2023، لأن البنائين لديهم خبرة سابقة في الاستفادة من برمجيات إكسبلويت 2b2t على نطاق أوسع، لذلك سيكون من الممتع رؤية ذلك يحدث مرة أخرى، وكان n0p الحصول على بالملل قليلا معها على أي حال. لقد قبلنا وبدأنا في تسجيل إحداثيات إسقاط العناصر على العديد من الحسابات التي كانت تقوم بالفعل بتعدين الحجر/الحصى على مدار الساعة طوال أيام الأسبوع لمشروع غير ذي صلة (لذلك، لم يكن هناك أي تغيير في السلوك). لقد أعدنا استخدام نظام Minecraft بدون رأس من nocom وأضفنا قاعدة بيانات Postgres لتسجيل القياسات. كما تمت مناقشته لاحقًا في هذا الملف التمهيدي، مررنا بعدة تكرارات للبرامج لكسر قياسات RNG، واستقرينا في النهاية على مهمة دفعة Cuda غير متزامنة. ومع إضافة القياسات المتشققة إلى قاعدة البيانات، قمنا أيضًا بتحديث جدول التحليلات بمعلومات الخريطة الحرارية التي تحسب النتائج عند كل إحداثي على فترات زمنية في كل الأوقات، يوميًا، وكل ساعة. سمح هذا لواجهة مستخدم Plotly Dash البسيطة بتحديد بيانات الخريطة الحرارية من نطاقات زمنية محددة وتفاصيل دقيقة لعرضها في المتصفح، كما أتاح لنا إزالة جميع الرسائل غير المرغوب فيها من مجموعة Elytra stashhunting من خلال النظر فقط في الإحداثيات التي تم تحميلها في أكثر من بضع ساعات مميزة. أضفنا نظامًا بسيطًا للتعليقات التوضيحية المشتركة لتتبع ما وجدناه في كل نقطة اتصال على الخريطة. مرة أخرى من خلال إعادة الاستخدام من Nocom، لدينا روبوتات Baritone التي تعمل على أتمتة العملية الكاملة لسرقة مخابئ العناصر وفرز النتائج، تمامًا AFK. ساعد العديد من الماسونيين في هذا الجزء، دون معرفة الاستغلال، وذلك باستخدام حسابات مثل munmap
و 1248_test_user
. زادت جميع مخابئ Gringotts معًا في النهاية إلى 1.3 مليار عنصر، نصفها تقريبًا يُعزى إلى Nocom والنصف الآخر إلى Randar.
تم شرح التاريخ الكامل في فيديو FitMC.
يتم إنشاء خريطة Minecraft من الناحية الإجرائية وهي حتمية بشكل أساسي بناءً على البذرة الأولية للعالم. عندما يستكشف اللاعبون الخريطة، يتم إنشاء مناطق جديدة عند الطلب عندما يقترب اللاعبون. نظرًا لأن كل الأجيال من المفترض أن تكون قابلة للتكرار (حتمية)، فمن المعقول تمامًا بالنسبة لهم استخدام java.util.Random
في الكثير من الأماكن. إنهم يريدون أن يكون الأمر قابلاً للتنبؤ به. وهذا هو سبب استخدام java.util.Random
، نظرًا لأنه PRNG (وليس RNG حقًا). يعني حرف P من الناحية الفنية "زائفًا" ولكن فكر فيه على أنه "يمكن التنبؤ به". RNG يمكن التنبؤ به. إنه يولد أرقامًا تبدو عشوائية، لكنها في الواقع قابلة للتكرار بالنظر إلى نفس بذرة البداية.
لدى Minecraft العديد من الهياكل التي يتم إنشاؤها في العالم، مثل القرى والمعالم الأثرية في المحيطات والمعاقل وما إلى ذلك. وهي جزء من الجيل الإجرائي، لذلك يتم وضعها وإنشاءها أيضًا بشكل حتمي.
لا يوجد سوى عشرات الأسطر من كود Minecraft اللازمة لفهم هذا، وقد قمت بتبسيطها والتعليق عليها بشدة:
// (chunkX,chunkZ) is being loaded, and this function checks if it should generate a Woodland Mansion
protected boolean canSpawnStructureAtCoords ( int chunkX , int chunkZ ) {
// divide by 80, rounding down, to determine which "Woodland region" (my made up term) we're considering
int woodlandRegionX = Math . floorDiv ( chunkX , 80 );
int woodlandRegionZ = Math . floorDiv ( chunkZ , 80 );
// seed the random number generator deterministically in a way that's unique to this Woodland region
Random random = this . world . setRandomSeed ( woodlandRegionX , woodlandRegionZ , 10387319 );
// pick which chunk within this region will get the Woodland Mansion
int woodlandChunkX = woodlandRegionX * 80 + ( random . nextInt ( 60 ) + random . nextInt ( 60 )) / 2 ;
int woodlandChunkZ = woodlandRegionZ * 80 + ( random . nextInt ( 60 ) + random . nextInt ( 60 )) / 2 ;
// but is it *this* chunk, that we're loading right now?
if ( chunkX == woodlandChunkX && chunkZ == woodlandChunkZ ) {
// and, is this chunk in a biome that allows Woodland Mansions? (e.g. roofed forest)
if ( this . world . getBiomeProvider (). areBiomesViable ( chunkX * 16 + 8 , chunkZ * 16 + 8 , 32 , ALLOWED_BIOMES )) {
return true ;
}
}
return false ;
}
// and here's what it calls in World.java:
public Random setRandomSeed ( int seedX , int seedY , int seedZ ) {
this . rand . setSeed ( seedX * 341873128712L + seedY * 132897987541L + seedZ + this . getWorldInfo (). getSeed ());
return this . rand ; // this.getWorldInfo().getSeed() is the overall seed of the entire map, which has been cracked long ago for 2b2t (it's -4172144997902289642)
}
تم التعليق على ما ورد أعلاه وتعديله قليلاً من أجل الوضوح، ولكنه دقيق وظيفيًا بالنسبة للكود الحقيقي.
لذا فإن الفكرة هي تحديد المكان الذي يجب أن يذهب إليه قصر الغابة في منطقة الغابة هذه (التي تبلغ مساحتها 80 × 80 قطعة)، والتحقق مما إذا كان هذا المكان موجودًا هنا ، وإذا كان الأمر كذلك، فقم بإنشاء قصر الغابة بدءًا من هنا.
قد يبدو هذا الرمز سخيفًا بعض الشيء، وقد تفكر في أنه "من السخف إجراء كل عمليات التحقق هذه على كل قطعة، ما عليك سوى اختيار المكان الذي يجب أن تذهب إليه Woodland Mansions مرة واحدة لكل منطقة والانتهاء منه". والسبب هو أن قطع Minecraft يتم إنشاؤها بشكل مستقل عن بعضها البعض، وبترتيب غير معروف، ومع ذلك ما زلنا نريد إنشاء عالم حتمي من بذرة معينة. لا نعرف الترتيب الذي سيسير به اللاعب حول العالم، ومن الجيد أن نكون قادرين على إنشاء أي قطعة حسب الطلب بطريقة عديمة الحالة. إنها تجربة لعبة جيدة. وبالتالي، رمز غريب المظهر مثل هذا.
على أي حال، يتم استدعاء هذا الرمز عند كل تحميل قطعة، لكل قطعة في مربع كبير حول القطعة التي يتم تحميلها. إن شرح السبب معقد بعض الشيء، لذلك سأتخطاه في الغالب (الفكرة الأساسية هي أن هذه الهياكل أكبر (كثيرًا) من حجم قطعة واحدة، لذلك نحتاج إلى التحقق من أصل البنية في العديد من القطع القريبة من أجل إنشاء هذا الحالي بشكل صحيح).
لاحظ أن هذا يؤثر فقط على العالم الخارجي. إن Nether آمن، حيث أن جميع عمليات إنشاء بنيته تستخدم دائمًا RNG الآمن. يتأثر تحميل القطع في النهاية بسبب المدن النهائية، ولكن فقط عند توليدها الأولي، وليس في كل مرة يتم تحميلها فيها، وبالتالي فإن النهاية آمنة نسبيًا لأن كل قطعة في قاعدتك تؤثر فقط على RNG مرة واحدة عند التحميل لأول مرة هو - هي. ومع ذلك، هذا ليس مضمونًا تمامًا، حيث يقوم اللاعبون عادةً بإنشاء قطع جديدة في كل مرة أثناء السفر إلى قاعدتهم، وأحيانًا يقومون بإنشاء قطع جديدة أثناء وجودهم في قاعدتهم بالفعل.
المشكلة هي أنه يعدل بذرة World.rand
العالمية. هذا مجرد ترميز كسول. كل ما يفعلونه هو الاتصال nextInt
أربع مرات لاختيار إحداثيات X وZ. كان من الممكن أن يستبدلوا Random random = this.world.setRandomSeed(...
بـ Random random = new Random(the same stuff)
(بمعنى آخر، أنشئ Random
جديدًا هنا بدلاً من العبث بالعشوائي الموجود الذي يستخدمه كل شيء آخر؟ ؟؟).
بشكل حاسم، يتم استدعاء setRandomSeed
للتحقق من المكان الذي يجب أن يذهب إليه قصر Woodland. يحدث ذلك بغض النظر عن أي شيء، على كل قطعة تحميل، في كل مكان. ليس من الضروري أن تقف في/بالقرب من Woodland Mansion أو أي شيء من هذا القبيل.
حسنًا، اتضح أن World.rand
يُستخدم في مئات الأماكن حرفيًا، ويمكن ملاحظة العديد من هذه الأماكن بسهولة من خلال لعب اللعبة بشكل طبيعي. على سبيل المثال، عندما تقوم بتعدين كتلة:
/**
* Spawns the given ItemStack as an EntityItem into the World at the given position
*/
public static void spawnAsEntity ( World world , BlockPos pos , ItemStack stack ) {
double xWithinBlock = world . rand . nextFloat () * 0.5F + 0.25D ;
double yWithinBlock = world . rand . nextFloat () * 0.5F + 0.25D ;
double zWithinBlock = world . rand . nextFloat () * 0.5F + 0.25D ;
EntityItem entityitem = new EntityItem ( world , pos . getX () + xWithinBlock , pos . getY () + yWithinBlock , pos . getZ () + zWithinBlock , stack );
world . spawnEntity ( entityitem );
}
مرة أخرى، تم تعديلها بشكل طفيف، ولكنها دقيقة من الناحية الوظيفية بالنسبة للأشياء التي نتحدث عنها.
الفكرة هنا هي أنه في لعبة Minecraft عندما تقوم بتعدين كتلة، فإنها تسقط عنصرًا. يتم إسقاط العنصر في موضع عشوائي داخل الكتلة. على سبيل المثال، إذا كانت الكتلة عند (10, 20, 30)
، فسيظهر العنصر في مكان ما بين (10.25, 20.25, 30.25)
و (10.75, 20.75, 30.75)
.
ويتم اختيار الموقع الدقيق لهذا العنصر عن طريق الاتصال بـ world.rand.nextFloat()
ثلاث مرات متتالية، لـ X وY وZ.
هذا هو كل ما تحتاجه من كود ماينكرافت!
لقد قلت الآن أنه يمكننا فعل شيء ما باستخدام مكالمات nextFloat
هذه. أولاً، دعونا نرى ما إذا كان بإمكاننا "الرجوع إلى الوراء" لمعرفة ما هي استدعاءات nextFloat
. إنه محظوظ جدًا، لكننا في الواقع نستطيع ذلك. لاحظ في الكود أعلاه: يتم ضرب التعويم العشوائي بـ 0.5، ثم يضاف إلى 0.25. الفكرة هي الانتقال من رقم عشوائي بين 0 و 1 إلى رقم عشوائي بين 0.25 و 0.75. ربما تشعر بالقلق، لأنه إذا قمت بقسمة عدد صحيح على اثنين، فسوف تفقد بعض المعلومات حيث يتم تقريب النتيجة إلى الأسفل. لحسن الحظ، فإن ضرب التعويم في 0.5 هو أمر قابل للعكس تمامًا، لأنه يقلل فقط من الأس بينما يترك الجزء العشري دون تغيير. بعد ذلك، يتم صب العوامة بشكل مزدوج، وهو ما يتميز بدقة أكبر. تتم إضافة 0.25، ثم تتم إضافة إحداثيات الكتلة. ومن ثم يتم إرسالها إلى العميل عبر الشبكة بدقة متناهية. النتيجة: هذه العملية برمتها قابلة للعكس حتى نتمكن من الحصول على العوامات الثلاثة التي أنتجها World.rand.nextFloat()
.
كيف يقوم java.util.Random
بإنشاء العوامات؟ حسنا في الواقع الأمر بسيط للغاية. يقوم بإنشاء عدد صحيح بين 0 و2^24، ثم يقسمه على 2^24 (ينتج عنه رقم بين 0 و1). كيف يمكن الحصول على هذا العدد الصحيح العشوائي؟ أيضا بسيطة جدا! إنه مولد متطابق خطي (LCG). هذا يعني أن البذرة التالية هي البذرة السابقة مضروبة في شيء ما، بالإضافة إلى شيء آخر، modulo شيء آخر.
public float nextFloat () {
this . seed = ( this . seed * multiplier + addend ) % modulus ; // update the seed
int randomInteger = ( int ) ( this . seed >> 24 ); // take the top 24 bits of the seed
return randomInteger / (( float ) ( 1 << 24 )); // divide it by 2^24 to get a number between 0 and 1
}
في هذه الحالة، المضاعف هو 25214903917، والإضافة هي 11، والمعامل هو 2^48.
باستخدام التعويم الذي خرج من هذا، يمكننا ضربه في 2^24 لاستعادة العدد الصحيح العشوائي، وبالتالي الحصول على "النصف العلوي" (أهم 24 بت) من البذرة ذات 48 بت.
باختصار، من قياسنا، نتعلم أن البذرة تقع بين measuredRandomInteger * 2^24
و (measuredRandomInteger + 1) * 2^24
.
ويمكننا أن نفعل ذلك ثلاث مرات متتالية، لـ X، وY، وZ.
ونحن نعلم أنه بين X وY، وبين Y وZ، تم تحديث البذرة وفقًا لـ newSeed = (oldSeed * 25214903917 + 11) mod 2^48
يجب أن أذكر أن أحد الخيارات الصالحة هو حلقة for التي تحاول استخدام جميع البتات الأقل الممكنة بمقدار 2^24. بالنسبة للمبرمجين الذين يقرأون هذا، آمل أن يوضح هذا ما هي المشكلة:
for ( long seed = firstMeasurement << 24 ; seed < ( firstMeasurement + 1 ) << 24 ; seed ++) {
// all these seeds will match the first measurement
if ( nextSeed ( seed ) >> 24 == secondMeasurement && nextSeed ( nextSeed ( seed )) >> 24 == thirdMeasurement ) {
// if nextSeed(seed) matches secondMeasurement, and nextSeed(nextSeed(seed)) matches thirdMeasurement
// then we found a seed that matches all three measurements! yay!
return seed ;
}
}
قد ينجح هذا، وهو يعمل بالفعل، لكنه ليس بهذه السرعة وليس بهذه المتعة. لذلك نستخدم المشابك بدلا من ذلك!
ومع ذلك، أشعر أنني يجب أن أبتعد قليلاً عن النظام. يأتي جزء تقليل الشبكة هنا ولكنه معقد حقًا وأراهن أنه سيكون معدل الاحتفاظ بالقارئ منخفضًا ولا أريد أن أخسرك. لذا، سأقدم لك حل for-loop (الذي ينجح)، ثم انتقل إلى الخطوة التالية من الاستغلال. سيأتي شرح طريقة تقليل الشبكة مباشرة بعد :)
ماذا نفعل بهذه البذرة عندما نحصل عليها؟
أولاً، لاحظ أنه يمكننا إرجاع الواقي الأساسي لإنقاذ الحياة إلى الوراء. من الواضح أن إضافة أحد عشر هو أمر قابل للعكس، ولكن هل الضرب بهذا العدد الكبير قابل للعكس؟ المضاعف 25214903917
هو رقم فردي، مما يعني أنه غير قابل للقسمة على اثنين، وبالتالي فهو لا يشارك أي عوامل مع معاملنا 2^48 (نظرًا لأن 2^48 هو حرفيًا مجرد مجموعة من الثنائيات). نظرًا لأنه أولي نسبيًا للمعامل، يمكننا عكسه، مما يعني العثور على رقم آخر x
يرضي x * 25214903917 - 1
يقبل القسمة على 2^48. أو بمعنى آخر 25214903917 * x mod 2^48 = 1
. هذا الرقم هو 246154705703781
. يساعد هذا في عكس الضرب لأنه إذا كان لدينا، على سبيل المثال، secret * 25214903917
ونريد معرفة secret
، فيمكننا فقط حساب secret * 25214903917 * 246154705703781 mod 2^48 = secret * 1 mod 2^48 = secret
.
حسنًا، حتى نتمكن من تحريك البذرة الداخلية لـ java.util.Random
للأمام والخلف. الأمام هو newSeed = (oldSeed * 25214903917 + 11) mod 2^48
والخلف هو oldSeed = ((newSeed - 11) * 246154705703781) mod 2^48
. وهذا يعمل لأن هذين الرقمين 25214903917
و 246154705703781
، عند ضربهما معًا، يخرجان إلى 1
عندما تأخذهما mod 2^48.
الآن، بينما نتراجع إلى الوراء، نود أن نتحقق في كل خطوة مما إذا كانت هذه البذرة قد تعني أن فحص Woodland Mansion قد تم إجراؤه مؤخرًا في مكان ما في العالم (الهدف الأساسي من الاستغلال). كيف نفعل ذلك؟
يتراوح عالم Minecraft من -30 مليون إلى +30 مليون قطعة. يبلغ حجم كل "منطقة وودلاند" (منطقة من العالم حيث يتم وضع قصر غابة واحد بشكل عشوائي، وفقًا للكود الموضح سابقًا) 80 × 80 قطعة، أي 1280 × 1280 قطعة. هذه هي 23437.5 منطقة وودلاند، ولكن بالنسبة لكل الكود الخاص بنا فقد قمنا للتو بتقريب الرقم إلى 23440 لأنه رقم تقريبي وعلى الرغم من أن لاعبك لا يمكنه السفر إلى أكثر من 30 مليونًا، إلا أنك تقوم بتحميل أجزاء أبعد من ذلك بمجرد الوقوف بالقرب منه، ونحن فقط لم أكن أرغب في القلق بشأن كل ذلك.
إذن، -23440 إلى +23440 على المحورين X وZ. هذه (23440*2+1)^2
(المعروفة أيضًا باسم 2197828161
) مناطق الغابات المحتملة، كل واحدة منها تولد "بذرة قصر" فريدة من نوعها (يتم تعريفها على أنها بذرة تكشف أن شخصًا ما قام للتو بتحميل قطعة في منطقة غابات معينة). نحن بحاجة إلى أن نكون قادرين على التحقق مما إذا كان هناك شيء ما هو بذرة القصر. هل يمكننا تكرار جميع بذور القصر البالغ عددها 2.2 مليارًا للتحقق من كل واحدة منها؟ سيكون بطيئا جدا. هل يمكن إنشاء HashSet
بـ 2.2 مليار إدخال؟ قد يستهلك الكثير من ذاكرة الوصول العشوائي (RAM) حتى باستخدام خريطة السجل كما فعلنا في nocom، وحتى في C++ باستخدام abseil-cpp
كان يستخدم مثل 50 جيجابايت من ذاكرة الوصول العشوائي. ناهيك عن الجزء الآخر: نحن في الواقع نريد أن نعرف أين هم في العالم (وهذا هو بيت القصيد). لذا، ليس من الجيد معرفة أن هذه بذرة قصر، فنحن نريد أيضًا أن نعرف (بكفاءة) منطقة الغابات التي تسببت في ذلك.
تذكر الدالة التي تنتقل من منطقة الغابة إلى بذرة القصر (ملاحظة: لقد قمت الآن بدمج بعض الثوابت منذ الكود أعلاه من أجل البساطة، وهذه المعادلة الآن مخصصة لبذور 2b2t ):
seed = x * 341873128712 + z * 132897987541 - 4172144997891902323 mod 2^48
(يأتي الرقم -4172144997891902323
من -4172144997902289642 + 10387319
، وهو بذرة العالم 2b2t + القيمة السحرية المستخدمة لبذر منطقة الغابات (كما هو موضح سابقًا). بالنسبة لأي عالم آخر، يمكنك فقط وضع بذرتك الخاصة بدلاً من ذلك في هذه المعادلة .)
ليس هناك الكثير مما يمكننا فعله بالإحداثي x، حيث أنه يتم ضربه بعدد زوجي. لكن ما هو هذا المعامل على الإحداثي z؟ يبدو أن الرقم غريب !!! دعونا نستخدم نفس الخدعة السابقة لعكسها مرة أخرى، وسنحصل على 211541297333629
.
دعونا نتخيل أن لدينا بذرة معينة. ماذا لو تمكنا من تكرار جميع إحداثيات X الممكنة من -23440 إلى +23440، ولكل منها، حساب الإحداثيات Z لمنطقة وودلاند، إذا كانت تحتوي على بذرة القصر هذه . بمعنى آخر، المعادلة أعلاه تعطينا seed
إذا كنا نعرف x
و z
، لكن هل يمكننا عمل معادلة تعطينا z
إذا كنا نعرف seed
و x
؟ الجواب: نعم. نحن فقط نعيد ترتيب المعادلة أعلاه، ونستخدم حقيقة أن معامل Z قابل للعكس 2^48 لأنه رقم فردي.
المعادلة هي:
z = (seed + 4172144997891902323 - x * 341873128712) * 211541297333629 mod 2^48
لذلك يعد هذا حلاً جيدًا جدًا لأنه بدلاً من حلقتين متداخلتين (واحدة لـ X وواحدة لـ Z) تقومان بإجمالي 2.2 مليار تكرار، يمكن أن يكون لدينا حلقة for واحدة لـ X تقوم فقط بـ 46881 تكرارًا. وهنا في جافا:
private static WoodlandRegionCoord woodlandValid ( long internalSeed ) {
long seed = 25214903917 ^ internalSeed ; // java.util.Random XORs in the multiplier while doing setSeed, so XOR that back out to go from a "this.seed" to what the input to setSeed() would be
for ( int x = - 23440 ; x <= 23440 ; x ++) {
long z = (( seed + 4172144997891902323L - x * 341873128712L ) * 211541297333629L ) << 16 >> 16 ;
if ( z >= - 23440 && z <= 23440 ) {
return new WoodlandRegionCoord ( x , z );
}
}
return null ;
}
(ملاحظة: الغريب << 16 >> 16
يقوم بالتعديل 2^48، ولكننا نريد فعل ذلك باستخدام الأنواع الموقعة حتى نتمكن من الحصول على الإجابة الصحيحة عندما يكون z بين -23440 و0، وهذه طريقة ل قم بتمديد رقم 48 بت إلى 64 بت، مع ملء الـ 16 بت العلوية ببت الإشارة الصحيح لمكملة اثنين)
إذن هذا يعمل وهو سريع بشكل معقول.... بالنسبة لبذرة واحدة. لكن تذكر أننا نقوم بإرجاع RNG لآلاف الخطوات المحتملة، ونجري هذا الفحص في كل خطوة حتى نجد تطابقًا. في ذلك الوقت، كنا نستخدم قطرة DigitalOcean الرديئة في المستوى الأدنى، وكان هذا في الواقع يؤخر كل شيء ولم نتمكن من مواكبة الوقت الفعلي (تستخرج الروبوتات العديد من الكتل في الثانية، وتستغرق كل كتلة آلاف الخطوات لاختراقها، وكل خطوة من خطوات rng تتطلب عمليات 23440*2+1 للتحقق منها، وضربها معًا وستحصل على مئات الملايين من العمليات في الثانية، لذلك ترى سبب حدوث مشكلة في خادم VPS سيئ، خاصة عندما يحاول هذا VPS أيضًا لتشغيل العديد من مثيلات Minecraft مقطوعة الرأس).
على أي حال، قمنا بالتبديل إلى نهج جدول البحث وأعدنا كتابته في Cuda ليتم تشغيله على سطح المكتب كمهمة مجمعة كل بضع دقائق. يمكن أن تفعل حرفيًا الملايين في الثانية لأن كل واحدة من آلاف نوى الكودا يمكنها العمل على بذورها الخاصة بالتوازي. إليك الفكرة: مفتاح جدول البحث هو الـ 32 بت السفلية من بذرة القصر، والقيمة هي الإحداثي X لمنطقة الغابات. يعمل جدول البحث هذا بدون أي تصادمات لأن كل بذرة قصر تحتوي على 32 بت فريدة من نوعها، بطريقة ما . لا أفهم لماذا هذا صحيح، إنه أمر رائع. كنت أعتقد أنه لن ينجح. لكنني أعتقد أن المعاملين 341873128712
و 132897987541
ربما تم اختيارهما خصيصًا لإنجاز هذا العمل؟ مثلًا، إذا كان لديك 2.2 مليار كرة رخامية، و4.3 مليار دلو، وقمت بوضع كل كرة بشكل مستقل في دلو عشوائي، ما هو احتمال أن تحصل كل كرة على دلو خاص بها؟ في الأساس صفر. مع اقتراب النهاية، تتمتع كل قطعة رخام جديدة بفرصة تزيد عن 50% في اصطدامها بدلو مملوء بالفعل. ومع ذلك، من الواضح أن هذه ليست عشوائية بشكل مستقل، لذا فهي تعمل بطريقة ما. من غير المفارقة إذا كنت تقرأ هذا وتفهم كيف يعمل هذا أو لماذا يعمل هذان المعاملان المحددان، فيرجى إخباري بذلك. على أية حال، فإنه يعمل. يحتوي جدول البحث على 2^32 إدخالاً، وكل إدخال يبلغ 2 بايت (نظرًا لأنه مجرد رقم بين -23440 و+23440)، لذلك يحتاج هذا إلى حوالي 9 غيغابايت من VRAM على وحدة معالجة الرسومات الخاصة بك.
تبدو الآن وظيفة فحص الغابات (مرة أخرى، هذا هو الكود الفعلي ولكنه مبسط، وجميع المساعدين والثوابت مضمنة وما إلى ذلك):
__global__ void computeSteps ( const int16_t * mansionTable, const int64_t * seedsArr, Result* resultArr, size_t numData) {
auto tid = blockIdx. x * blockDim. x + threadIdx. x ;
[[unlikely]] if (tid >= numData) {
return ;
}
auto seed = seedsArr[tid];
int steps = 0 ;
while ( true ) {
auto externalSeed = seed ^ 25214903917 ;
const auto x = mansionTable[externalSeed & (( 1LL << 32 ) - 1 )];
const auto z = ((externalSeed + 4172144997891902323LL - ( int64_t ) x * 341873128712LL ) * 211541297333629LL ) << 16 >> 16 ;
if (z >= - 23440 & z <= 23440 ) {
resultArr[tid] = {. startSeed = seedsArr[tid], . x = ( int16_t ) x, . z = ( int16_t ) z, . steps = steps};
return ;
}
seed = ((seed - 0xBLL ) * 0xdfe05bcb1365LL ) & (( 1LL << 48 ) - 1 ); // prevSeed(seed)
steps++;
}
}
// and that mansionTable was generated like this
// note: mansionTable must be calloc'd before this function because not every entry will be written to, and an x value outside -23440 to 23440 bounds could create a false positive later on while using the table
__global__ void writeToSeedTable ( int16_t * mansionTable) {
auto tid = blockIdx. x * blockDim. x + threadIdx. x ;
if (tid >= ( 23440 * 2 + 1 ) * ( 23440 * 2 + 1 )) return ;
auto x = tid / ( 23440 * 2 + 1 ) - 23440 ;
auto z = tid % ( 23440 * 2 + 1 ) - 23440 ;
auto seed = (( int64_t ) x * 341873128712LL + ( int64_t ) z * 132897987541LL - 4172144997891902323LL ) & (( 1LL << 48 ) - 1 );
mansionTable[seed & (( 1LL << 32 ) - 1 )] = ( int16_t ) x;
}
يعمل هذا بشكل رائع على دفعات عملاقة ويمكن أن يتكسر بمقدار عشرة ملايين بذرة في الثانية على 3090. وقد تبين أن هذه ليست مشكلة كبيرة عندما تنتهي بعض الخيوط في الالتواء مبكرًا، ولم نتمكن حقًا من تحقيق ذلك أي أسرع من هذا. (السبب هو أننا لا نستطيع أن نعرف مسبقًا أي البذور ستتخذ خطوات أكثر/أقل).
حسنًا، هذا كل ما في الأمر. بالنظر إلى البذور، هذه هي الطريقة التي نحصل بها على منطقة الغابات في العالم حيث حدث آخر حمل للقطعة. بمعنى آخر، علمنا للتو أن آخر مرة تجول فيها شخص ما على 2b2t وقام بتحميل منطقة جديدة من العالم، كانت في مكان ما داخل منطقة Woodland التي تبلغ مساحتها 1280 × 1280 والتي حددناها للتو. (وهذا دقيق بدرجة كافية بحيث يستغرق تحديد موقعهم بضع دقائق فقط من البحث)
من الناحية العملية، كم عدد خطوات RNG اللازمة؟ في النهاية المنخفضة، تبدأ القياسات الموثوقة عند 4 خطوات RNG، وكل شيء أدناه هو خطأ في القياس / ضوضاء عشوائية، وهو ما نعرفه لأن كود Woodland Mansion يستدعي rand.nextInt
على الفور أربع مرات قبل أن نتمكن من قياسه. في المتوسط، هناك حوالي 128000 خطوة بين كل بذرة وودلاند، ولكن من الناحية العملية، في الغالبية العظمى من الوقت في 2b2t، وجدنا بذرة وودلاند ضمن بضع عشرات من الخطوات. ويرجع ذلك إلى تفاصيل ما يحدث وبأي ترتيب في علامة Minecraft. يتم قياسنا تقنيًا في بداية العلامة، حيث تتم معالجة الحزم الخاصة بكسر الكتل. بشكل عام، تم تحميل القطعة في السجل الحديث جدًا خلال العلامة السابقة. ومع ذلك، في بعض الأحيان، قد يتسبب حدث ما في حدوث مجموعة من خطوات RNG بينهما. نعتقد أن هذا الحدث عبارة عن انفجارات، مثل قيام شخص ما بتفجير بلورة نهائية عن طريق لكمها، أو ربما ذوبان الجماجم. قد تحدث انفجارات بلورية نهائية أيضًا أثناء معالجة الحزم من حزمة لكمة المشغل، ويصطف عدد خطوات RNG أيضًا عند 1354 خطوة (1352 من حساب ضرر الكتلة في مكعب
وعلى الإحداثيات المستخدمة كمثال عملي في هذا الرسم البياني، هذا ما يخرجه الكود أعلاه:
jshell> crackItemDropCoordinate(0.730696, 0.294929355, 0.634865435)
Item drop appeared at 0.730696 0.294929355 0.634865435
RNG measurements are therefore 16129481 1507579 12913941
This indicates the java.util.Random internal seed must have been 270607788940196
Found a woodland match at woodland region 123 456 which would have set the seed to 261215197308064
Located someone between 157312,583552 and 158591,584831
jshell>
لاحظ كيفية تحديد موقع Woodland Region 123,456
، ولاحظ كيف أن "تحديد موقع شخص ما" النهائي يتضمن الإحداثيات الحقيقية التي أدخلناها في الأصل، والتي كانت x=157440 z=583680. بالإضافة إلى ذلك، تتطابق قياسات RNG مع الرقم الست عشري باللون الأحمر: 0xf61dc9
يساوي 16129481
، و 0x1700fb
يساوي 1507579
، و 0xc50d15
يساوي 12913941
. وبالنسبة للبذور 0xed92e70ba4a0
يساوي 261215197308064
و 0xf61dc9221ba4
يساوي 270607788940196
.
ربما يمكنك العثور على تصحيحات أو خيارات تكوين لتعطيل معالجة RNG، وسيعمل شيء من هذا القبيل على تصحيح Randar وربما تكون هذه هي الطريقة الأسهل. إذا لم تتمكن من العثور على طريقة سهلة لتعطيل معالجة RNG، فإليك الكود الذي يحتاج إلى تعديل في المستوى World
:
النسخة الضعيفة:
public Random setRandomSeed ( int seedX , int seedY , int seedZ ) {
this . rand . setSeed ( seedX * 341873128712L + seedY * 132897987541L + seedZ + this . getWorldInfo (). getSeed ());
return this . rand ;
}
ما عليك سوى تغيير هذه الوظيفة لإرجاع عشوائي مختلف في كل مرة، إذا كنت تريد الحماية المثالية:
النسخة المصححة:
public Random setRandomSeed ( int seedX , int seedY , int seedZ ) {
return new Random ( seedX * 341873128712L + seedY * 132897987541L + seedZ + this . getWorldInfo (). getSeed ());
}
قد لا يكون لهذا أداءً رائعًا، لذلك إذا أردت، يمكنك تقديم حقل جديد، separateRandOnlyForWorldGen
، والذي لا تتم مشاركته مع أي شيء آخر، على سبيل المثال:
private final Random separateRandOnlyForWorldGen = new Random (); // new field on the World class
public Random setRandomSeed ( int seedX , int seedY , int seedZ ) {
this . separateRandOnlyForWorldGen . setSeed ( seedX * 341873128712L + seedY * 132897987541L + seedZ + this . getWorldInfo (). getSeed ());
return this . separateRandOnlyForWorldGen ;
}
إذا كنت تستخدم PaperMC للإصدار 1.12.2 ، فإليك التصحيح الذي يمكنك تطبيقه. الرابط البديل.
سيكون هذا قسمًا إضافيًا أتناول فيه بعض الأشياء الإضافية التي قد يكون من المنطقي شرحها من وجهة نظري، بخلاف الأفكار الأساسية، قمنا في الغالب بتطوير الأشياء بشكل مستقل.
أول شيء أود أن أذكره هو نظام تحديد الإحداثيات من البذرة. استخدم Mason جدول بحث كبير ومعالجة GPU، وبدلاً من ذلك اعتمدت على ذاكرة التخزين المؤقت للسرعة. كلما حدثت ضربة، يتم وضع إحداثياتها وجميع الإحداثيات داخل نصف القطر في HashMap. تتم معالجة البذور في مسارين. يؤدي التمرير الأول إلى إرجاع RNG إلى الوراء، والتحقق مما إذا كانت البذرة موجودة في ذاكرة التخزين المؤقت، أو أنها نفس البذرة التي تمت معالجتها في المرة الأخيرة، وهو ما يتم اعتباره بشكل مختلف. يحدث التمرير الثاني فقط في حالة فشل التمرير الأول، ويكون أبطأ بكثير، ويستخدم الخوارزمية المكلفة نسبيًا الموصوفة مسبقًا. أحد الآثار الجانبية اللطيفة لهذا النظام هو أن التمريرة الأولى لديها القدرة على "تخطي" مكان "صالح" بخلاف ذلك، ولكن من غير المرجح أن تكون الموقع الصحيح، مما يساعد في الحصول على نتائج إيجابية كاذبة.
وهنا هذا الرمز:
public class RandarCoordFinder
{
public static final long X_MULT = 341873128712L ;
public static final long Z_MULT = 132897987541L ;
public static final long Z_MULT_INV = 211541297333629L ;
public static final int MANSION_SALT = 10387319 ;
public static final int MANSION_SPACING = 80 ;
public static final int CITY_SALT = 10387313 ;
public static final int CITY_SPACING = 20 ;
// the last seed we processed
public long lastSeed = - 1 ;
// a mapping of seed -> x,z that is updated everytime we get a hit
public final HashMap < Long , Long > hitCache = new HashMap <>();
// set this according to the server's seed
public long worldSeed ;
// change these if you need to use different structures
public int salt = MANSION_SALT ;
public int spacing = MANSION_SPACING ;
public RandarCoordFinder ( long worldSeed )
{
this . worldSeed = worldSeed ;
}
// a simple class that extends java.util.Random and provides some extra methods and constants we need
public static class RandarRandom extends Random
{
public static final long MULT = 0x5DEECE66DL ;
public static final long ADDEND = 0xBL ;
public static final long MASK = ( 1L << 48 ) - 1 ;
public static final long MULT_INV = 0xDFE05BCB1365L ;
public long seed ;
public RandarRandom ( long seed )
{
this . seed = seed ;
}
@ Override
public void setSeed ( long seed )
{
this . seed = seed ;
}
@ Override
public int next ( int bits )
{
seed = ( seed * MULT + ADDEND ) & MASK ;
return ( int )( seed >> 48 - bits );
}
public int prevInt ()
{
seed = (( seed - ADDEND ) * MULT_INV ) & MASK ;
return ( int )( seed >> 16 );
}
}
public enum FindType
{
HIT ,
SKIP ,
FAIL ;
}
public record FindResult ( FindType type , int xCoord , int zCoord , int steps )
{
}
public FindResult findCoordsSeed ( long seed , int maxSteps )
{
seed &= RandarRandom . MASK ;
// remember and update lastSeed
long last = lastSeed ;
lastSeed = seed ;
RandarRandom random = new RandarRandom ( seed );
// first pass - this is meant to be quick
for ( int i = 0 ; i < maxSteps + 100000 ; i ++)
{
if ( random . seed == last && i > 0 )
{
// we encountered the last processed seed while stepping back, skip
return new FindResult ( FindType . SKIP , 0 , 0 , i );
}
else
{
Long hashValue = hitCache . get ( random . seed );
if ( hashValue != null )
{
// we found a hit in our cache
int xCoord = ( int )(( hashValue >> 32 ) & 0xFFFFFFFF );
int zCoord = ( int )( hashValue & 0xFFFFFFFF );
cacheNearby ( xCoord , zCoord , 8 );
return new FindResult ( FindType . HIT , xCoord , zCoord , i );
}
}
random . prevInt ();
}
random . seed = seed ;
// second pass - this is slow and should only happen if the first pass fails
for ( int i = 0 ; i < maxSteps ; i ++)
{
// undo worldSeed and salt
long seedValue = ( random . seed ^ RandarRandom . MULT ) - worldSeed -
( long ) salt ;
Coords coords = findCoords ( seedValue , 1875000 / spacing + 8 );
if ( coords != null )
{
// we found a hit
cacheNearby ( coords . x , coords . z , 8 );
return new FindResult ( FindType . HIT , coords . x , coords . z , i );
}
random . prevInt ();
}
// we could not find anything
return new FindResult ( FindType . FAIL , 0 , 0 , - 1 );
}
public static long getRandomSeed ( int x , int z , int salt , long seed )
{
return (( long ) x * X_MULT + ( long ) z * Z_MULT ) + seed + ( long ) salt ;
}
private void cacheNearby ( int x , int z , int radius )
{
for ( int xOff = - radius ; xOff <= radius ; xOff ++)
{
for ( int zOff = - radius ; zOff <= radius ; zOff ++)
{
int cacheX = x + xOff ;
int cacheZ = z + zOff ;
long cacheSeed = ( getRandomSeed ( cacheX , cacheZ , salt ,
worldSeed ) ^ RandarRandom . MULT ) & RandarRandom . MASK ;
hitCache . put ( cacheSeed , ( long ) cacheX << 32 | cacheZ &
0xFFFFFFFFL );
}
}
}
public record Coords ( int x , int z )
{
}
public static Coords findCoords ( long value , int distance )
{
value &= RandarRandom . MASK ;
for ( int x = - distance ; x <= distance ; x ++)
{
long testValue = ( value - X_MULT * x ) & RandarRandom . MASK ;
long z = ( testValue * Z_MULT_INV ) << 16 >> 16 ;
if ( Math . abs ( z ) <= distance )
{
return new Coords ( x , ( int ) z );
}
}
return null ;
}
}
شيء آخر أود أن أذكره هو كيف استخدمت هذا في النهاية. كما ذكرنا سابقًا، تؤثر القطع الموجودة في The End على RNG مرة واحدة فقط عندما يتم إنشاؤها لأول مرة. وهذا يجعل الأمور أكثر تعقيدًا، لأنه على عكس العالم الخارجي، لا يمكن العثور على اللاعب بمجرد تحميل قطعة في قاعدته.
وبدلاً من ذلك، هناك سيناريوهان رئيسيان آخران يجب أن نعتمد عليهما:
السيناريو الأول يعني بشكل أساسي أنه لا يزال بإمكاننا استخدام الطريقة الساذجة المتمثلة في حساب عدد المرات المختلفة التي تم فيها ضرب منطقة ما، ومع ذلك، سنكون محدودين للغاية نظرًا لأن الضربات قد تكون نادرة جدًا، ويمكن أن يربكها شخص ما يطير ببساطة عبر منطقة ما. عدة مرات متميزة بما فيه الكفاية. أما السيناريو الثاني فيتطلب منا تحديد هذه المسارات ومتابعتها.
إذًا كيف نتبع المسارات بالضبط؟ من الناحية النظرية، يمكنك إنشاء نظام لتحديد المسارات ومتابعتها تلقائيًا، لكنني لم أنفذ هذا مطلقًا وقمت فقط بمتابعة المسارات يدويًا بصريًا. عند اتباع المسارات، هناك بعض الأفكار التي يمكن أن تساعد. على سبيل المثال، من المحتمل أن تعني الممرات المتعددة المؤدية إلى نفس المكان وجود قاعدة. إن معرفة أن ضربة أو أثرًا معينًا سببه لاعب معين يمكن أن يساعد أيضًا، المزيد عن ذلك لاحقًا.
إذًا كيف يمكننا معرفة اللاعب الذي تسبب في إصابة معينة؟ في Overworld، يمكننا ببساطة البحث عن النتائج "المميزة" التي تحدث مباشرة بعد انضمام اللاعب. ومع ذلك، من غير المرجح أن ينجح هذا هنا، لذا يجب علينا أن نفعل شيئًا آخر. يوجد بالفعل نظام أنيق لهذا الغرض. يعتمد ذلك على افتراض عدم وجود عدد كبير جدًا من اللاعبين المتصلين بالإنترنت في The End في الوقت نفسه، وفكرة أنه يمكننا معرفة من هم هؤلاء اللاعبين. الفكرة هي أن عدد استدعاءات RNG لكل علامة يرتبط جزئيًا بعدد القطع المحملة حاليًا، وبالتالي عدد اللاعبين في هذا البعد. من خلال مراقبة الزيادة أو النقصان المفاجئ في عدد هذه المكالمات مباشرة بعد انضمام اللاعب أو مغادرته على التوالي، يمكننا تحديد اللاعبين الموجودين في النهاية. سوف نسمي هذا النظام "متتبع نهاية الإشغال" (EOT).
تحتفظ EOT بمجموعتين. الأول يتتبع من نعتقد أنه موجود في النهاية "الآن". يمكن أن يؤدي هذا إلى تفويت اللاعبين، لذلك يعتبر أكثر عرضة للسلبيات الكاذبة. والثاني يتتبع من نعتقد أنه كان في "النهاية" "بشكل عام"، ذهابًا وإيابًا لفترة معينة من الوقت. يتم دمج هذا مع اللاعبين المتصلين حاليًا، ويعتبرون أكثر عرضة للإيجابيات الكاذبة. من خلال النظر إلى هذه المجموعات عند حدوث إصابة، وإجراء بعض الاستدلال اليدوي، يمكننا الحصول على فكرة تقريبية حول من قد يكون سببًا في إصابة معينة.
تجدر الإشارة إلى أن EOT لم يتم اختبارها إلا على 9B9T وقد تعتمد حاليًا على الظروف التي قد لا تكون صحيحة على خوادم أخرى مثل 2B2T. يفترض أن RNG يمكن أخذ عينات من كل علامة دون تقلبات كبيرة ، والتي قد تكون أكثر صعوبة بالنسبة إلى 2B2T بسبب حد سرعة مكان الكتلة. قد تكون الأمور أكثر صعوبة إذا كان هناك نشاط لاعب أكبر بكثير في النهاية على الخادم ، وهو ما يمكن أن يكون صحيحًا على الأرجح لـ 2B2T لأنه خادم أكبر بكثير.