استرجاع المستويات من Super Mario Bros باستخدام Python


مقدمة


بالنسبة لمشروع جديد ، كنت بحاجة إلى استخراج بيانات المستوى من لعبة الفيديو الكلاسيكية 1985 Super Mario Bros (SMB) . بشكل أكثر تحديدًا ، كنت أرغب في استخراج رسومات الخلفية لكل مستوى من مستويات اللعبة بدون واجهة ، وتحريك النقوش المتحركة ، وما إلى ذلك.

بالطبع ، يمكنني فقط لصق الصور من اللعبة ، وربما أتمتة العملية باستخدام تقنيات رؤية الماكينة. ولكن بدا لي أكثر إثارة للاهتمام الطريقة الموضحة أدناه ، والتي تسمح لك باستكشاف عناصر المستوى تلك التي لا يمكن الحصول عليها باستخدام لقطات الشاشة.

في المرحلة الأولى من المشروع ، سوف نتعلم لغة التجميع 6502 ومحاكي مكتوب بلغة Python. كود المصدر الكامل متاح هنا .

تحليل شفرة المصدر


تكون الهندسة العكسية لأي برنامج أبسط بكثير إذا كان لديك شفرة المصدر الخاصة بها ، ولدينا مصادر SMB في شكل 17 ألف سطر من كود التجميع 6502 (معالج NES) تم نشره بواسطة doppelganger. نظرًا لأن Nintendo لم تصدر مطلقًا إصدارًا رسميًا للمصدر ، فقد تم إنشاء الرمز عن طريق تفكيك رمز جهاز SMB ، وفك شفرة معاني كل جزء بشكل مؤلم ، وإضافة تعليقات وأسماء رمزية ذات معنى.

بعد إجراء بحث سريع على الملف ، وجدت شيئًا مشابهًا لبيانات المستوى التي نحتاجها:

;level 1-1
L_GroundArea6:
.db $50, $21
.db $07, $81, $47, $24, $57, $00, $63, $01, $77, $01
.db $c9, $71, $68, $f2, $e7, $73, $97, $fb, $06, $83
.db $5c, $01, $d7, $22, $e7, $00, $03, $a7, $6c, $02
.db $b3, $22, $e3, $01, $e7, $07, $47, $a0, $57, $06
.db $a7, $01, $d3, $00, $d7, $01, $07, $81, $67, $20
.db $93, $22, $03, $a3, $1c, $61, $17, $21, $6f, $33
.db $c7, $63, $d8, $62, $e9, $61, $fa, $60, $4f, $b3
.db $87, $63, $9c, $01, $b7, $63, $c8, $62, $d9, $61
.db $ea, $60, $39, $f1, $87, $21, $a7, $01, $b7, $20
.db $39, $f1, $5f, $38, $6d, $c1, $af, $26
.db $fd


إذا لم تكن معتادًا على أداة التجميع ، فسوف أشرح: كل هذا يعني ببساطة "إدراج مثل هذه المجموعة من وحدات البايت في البرنامج L_GroundArea6 ، ثم السماح لأجزاء أخرى من البرنامج بالإشارة إليها باستخدام رمز L_GroundArea6 ". يمكنك أن تأخذ هذا الجزء كمصفوفة يكون فيها كل عنصر بايت.

أول شيء يمكنك ملاحظته هو أن حجم البيانات صغير جدًا (حوالي 100 بايت). لذلك ، نستبعد جميع أنواع الترميز ، مما يسمح لك بوضع الكتل بشكل تعسفي على المستوى. بعد البحث قليلاً ، وجدت أن هذه البيانات تتم قراءتها (بعد عدة عمليات عنونة غير مباشرة) في AreaParserCore . تستدعي هذه العملية الفرعية بدورها العديد من الإجراءات الفرعية الأخرى ، وتستدعي في النهاية إجراءات فرعية محددة لكل نوع من الكائنات المسموح بها في المشهد (على سبيل المثال ، StaircaseObject و VerticalPipe و RowOfBricks ):


الرسم البياني المكالمة AreaParserCore ل AreaParserCore

يكتب الإجراء إلى MetatileBuffer : قسم ذاكرة 13 بايت ، وهو عمود واحد من الكتل في مستوى ، يمثل كل بايت منها كتلة منفصلة. Metatile عبارة عن كتلة 16x16 تتكون منها خلفيات لعبة SMB:


مستوي مع مستطيلات محاطة بدوار حول metatiles

يطلق عليها الملفات الفوقية ، لأن كل منها يتكون من أربعة مربعات 8 × 8 بكسل ، ولكن أكثر من ذلك أدناه.

تشرح حقيقة أن مفكك التشفير يعمل مع كائنات محددة مسبقًا الحجم الصغير للمستوى: يجب أن تشير بيانات المستوى فقط إلى أنواع الكائنات وموقعها ، على سبيل المثال ، "ضع الأنبوب عند النقطة (20 ، 16) ، عدد من الكتل عند النقطة (10 ، 5) ، ... ". ومع ذلك ، هذا يعني أن الأمر يتطلب الكثير من التعليمات البرمجية لتحويل بيانات المستوى الخام إلى ملفات تعريف.

سيستغرق نقل هذا المقدار من الكود لإنشاء أداة فتح الحزم الخاصة بك الكثير من الوقت ، لذلك دعونا نجرب نهجًا مختلفًا.

py65emu


إذا كانت لدينا واجهة بين Python ولغة التجميع 6502 ، فيمكننا استدعاء AreaParserCore لكل عمود مستوى ، ثم استخدام Python أكثر قابلية للفهم لتحويل معلومات الكتلة إلى الصورة المطلوبة.

ثم يظهر py65emu على المشهد - محاكي 6502 موجز بواجهة Python. إليك كيفية تكوين نفس الذاكرة في py65emu كما هو الحال في NES:

  from py65emu.cpu import CPU from py65emu.mmu import MMU #  ROM  (..  ) with open("program.bin", "rb") as f: prg_rom = f.read() #   . mmu = MMU([ #  2K ,    0x0. (0x0, 2048, False, []), #  ROM   0x8000. (0x8000, len(prg_rom), True, list(prg_rom)) ]) #     ,       0x8000 cpu = CPU(mmu, 0x8000) 

بعد ذلك ، يمكننا تنفيذ تعليمات فردية باستخدام طريقة cpu.step() ، وفحص الذاكرة باستخدام mmu.read() ، ودراسة تسجيلات الجهاز باستخدام cpu.ra ، cpu.r.pc ، إلخ. بالإضافة إلى ذلك ، يمكننا الكتابة إلى الذاكرة باستخدام mmu.write() .

تجدر الإشارة إلى أن هذا مجرد محاكي معالج NES: فهو لا يحاكي أجهزة أخرى ، مثل PPU (وحدة معالجة الصور) ، لذلك لا يمكن استخدامه لمحاكاة اللعبة بأكملها. ومع ذلك ، يجب أن يكون كافيًا استدعاء الإجراء الفرعي للتحليل ، لأنه لا يستخدم أي أجهزة أخرى باستثناء وحدة المعالجة المركزية والذاكرة.

تتمثل الخطة في تكوين وحدة المعالجة المركزية كما هو موضح أعلاه ، ثم لكل أقسام المستوى ، قم بتهيئة أقسام الذاكرة بقيم الإدخال المطلوبة لـ AreaParserCore ، واستدعاء AreaParserCore ، ثم قم بقراءة بيانات العمود مرة أخرى. بعد الانتهاء من هذه العمليات ، نستخدم Python لتجميع النتيجة في صورة نهائية.

ولكن قبل ذلك ، نحتاج إلى تجميع القائمة بلغة التجميع في رمز الآلة.

× 816


كما هو موضح في كود المصدر ، يتم تجميع المجمع باستخدام x816. x816 هو مجمّع MS-DOS 6502 يستخدمه مجتمع البيرة المحلية لقراصنة NES و ROM. يعمل بشكل رائع على DOSBox .

جنبا إلى جنب مع ROM من البرنامج ، وهو أمر ضروري ل py65emu ، يقوم مجمّع x816 بإنشاء ملف حرف يعيّن الأحرف إلى موقعها في الذاكرة في مساحة عنوان وحدة المعالجة المركزية. هنا مقتطف من الملف:

AREAPARSERCORE = $0093FC ; <> 37884, statement #3154
AREAPARSERTASKCONTROL = $0086E6 ; <> 34534, statement #1570
AREAPARSERTASKHANDLER = $0092B0 ; <> 37552, statement #3035
AREAPARSERTASKNUM = $00071F ; <> 1823, statement #141
AREAPARSERTASKS = $0092C8 ; <> 37576, statement #3048


هنا نرى أنه يمكن الوصول إلى الدالة AreaParserCore في التعليمات البرمجية المصدر على 0x93fc .

من أجل الراحة ، كتبت محلل ملف رمز يطابق أسماء الرموز والعناوين:

 sym_file = SymbolFile('SMBDIS.SYM') print("0x{:x}".format(sym_file['AREAPARSERCORE'])) #  0x93fc print(sym_file.lookup_address(0x93fc)) #  "AREAPARSERCORE" 

الإجراءات الفرعية


كما هو موضح في الخطة أعلاه ، نريد أن نتعلم كيفية استدعاء AreaParserCore من Python.

لفهم آليات الإجراء الفرعي ، دعنا نفحص الإجراء الفرعي القصير والتحدي المقابل له:

 WritePPUReg1: sta PPU_CTRL_REG1 ;  A   1 PPU sta Mirror_PPU_CTRL_REG1 ;    rts ... jsr WritePPUReg1 

jsr (الانتقال إلى روتين فرعي ، "القفز إلى روتين") تسجيل جهاز الكمبيوتر على المكدس ويعينه قيمة العنوان التي يشير إليها WritePPUReg1 . يخبر سجل الكمبيوتر المعالج المعالج عن التعليمات التالية ليتم تحميلها ، بحيث تكون التعليمات التالية التي يتم تنفيذها بعد تعليمات WritePPUReg1 هي السطر الأول من WritePPUReg1 .

في نهاية الروتين ، يتم تنفيذ بيان rts (العودة من الروتين ، "العودة من الروتين"). يزيل هذا الأمر القيمة المخزنة من المكدس ويخزنها في سجل الكمبيوتر ، مما يجبر وحدة المعالجة المركزية على تنفيذ التعليمات التالية لاستدعاء jsr .

ميزة رائعة من الإجراءات الفرعية هي أنه يمكنك إنشاء مكالمات مضمنة ، أي مكالمات الإجراءات الفرعية ضمن الإجراءات الفرعية. سيتم دفع عناوين الإرجاع إلى المكدس وإبرازها بالترتيب الصحيح ، تمامًا كما هو الحال مع مكالمات الوظائف بلغات عالية المستوى.

هنا هو رمز تنفيذ روتين فرعي من Python:

 def execute_subroutine(cpu, addr): s_before = cpu.rs cpu.JSR(addr) while cpu.rs != s_before: cpu.step() execute_subroutine(cpu, sym_file['AREAPARSERCORE']) 

يحفظ الكود القيمة الحالية لسجل (سجلات) مؤشر المكدس ، ويحاكي استدعاء jsr ، ثم ينفذ التعليمات حتى يعود المكدس إلى ارتفاعه الأصلي ، والذي يحدث فقط بعد عودة الإجراء الفرعي الأول. سيكون هذا مفيدًا ، لأن لدينا الآن طريقة للاتصال برقم 6502 روتينًا فرعيًا من Python.

ومع ذلك ، نسينا شيئًا ما: كيفية تمرير قيم الإدخال لهذا الإجراء الفرعي؟ نحتاج إلى تحديد الإجراء الذي نريد تقديمه والعمود الذي نحتاج إلى تحليله.

على عكس الوظائف في اللغات عالية المستوى ، لا يمكن أن تتلقى الروابط الفرعية للغة التجميع 6502 بيانات إدخال محددة صراحة. بدلاً من ذلك ، يتم إرسال الإدخال عن طريق تحديد مواقع الذاكرة في مكان ما قبل المكالمة ، والتي تتم قراءتها بعد ذلك داخل استدعاء الإجراء الفرعي. بالنظر إلى حجم AreaParserCore ، فإن الهندسة العكسية للمدخلات المطلوبة بمجرد النظر إلى شفرة المصدر ستكون معقدة للغاية وعرضة للخطأ.

Valgrind ل NES؟


للعثور على طريقة لتحديد قيم إدخال AreaParserCore ، استخدمت أداة memcheck لـ Valgrind كمثال. يتعرف Memcheck على عمليات الوصول إلى ذاكرة غير مهيأة عن طريق تخزين ذاكرة الظل بالتوازي مع كل جزء من الذاكرة المخصصة الفعلية. تسجل ذاكرة الظل ما إذا تم التسجيل على الذاكرة الحقيقية المقابلة. إذا قرأ البرنامج على العنوان الذي لم يكتب عليه مطلقًا ، فسيتم إخراج خطأ ذاكرة غير مهيأ. يمكننا تشغيل AreaParserCore باستخدام أداة تخبرنا عن المدخلات التي يجب تعيينها قبل استدعاء AreaParserCore .

في الواقع ، كتابة نسخة بسيطة من memcheck لـ py65emu أمر سهل للغاية:

 def format_addr(addr): try: symbol_name = sym_file.lookup_address(addr) s = "0x{:04x} ({}):".format(addr, symbol_name) except KeyError: s = "0x{:04x}:".format(addr) return s class MemCheckMMU(MMU): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._uninitialized = array.array('B', [1] * 2048) def read(self, addr): val = super().read(addr) if addr < 2048: if self._uninitialized[addr]: print("Uninitialized read! {}".format(format_addr(addr))) return val def write(self, addr, val): super().write(addr, val) if addr < 2048: self._uninitialized[addr] = 0 

هنا قمنا بلف وحدة إدارة الذاكرة (MMU) في py65emu. تحتوي هذه الفئة على مجموعة _uninitialized ، والتي تخبرنا عناصرها ما إذا كان قد تم كتابتها إلى البايت المقابل من ذاكرة الوصول العشوائي التي تمت مضاهاتها. في حالة وجود قراءة غير مهيأة ، يتم عرض عنوان عملية القراءة غير الصالحة واسم الحرف المقابل.

فيما يلي نتائج MMU execute_subroutine(sym_file['AREAPARSERCORE']) عند استدعاء execute_subroutine(sym_file['AREAPARSERCORE']) :

Uninitialized read! 0x0728 (BACKLOADINGFLAG):
Uninitialized read! 0x0742 (BACKGROUNDSCENERY):
Uninitialized read! 0x0741 (FOREGROUNDSCENERY):
Uninitialized read! 0x074e (AREATYPE):
Uninitialized read! 0x075f (WORLDNUMBER):
Uninitialized read! 0x0743 (CLOUDTYPEOVERRIDE):
Uninitialized read! 0x0727 (TERRAINCONTROL):
Uninitialized read! 0x0743 (CLOUDTYPEOVERRIDE):
Uninitialized read! 0x074e (AREATYPE):
...


بالنظر إلى الشفرة ، يمكنك أن ترى أن العديد من هذه القيم يتم تعيينها من خلال الإجراءات الفرعية InitializeArea ، لذا فلنقم بتشغيل البرنامج النصي مرة أخرى ، مع استدعاء هذه الوظيفة أولاً. بتكرار هذه العملية ، نأتي إلى التسلسل التالي من المكالمات ، الأمر الذي يتطلب فقط الرقم العالمي ورقم المنطقة:

 mmu.write(sym_file['WORLDNUMBER'], 0) #    1 mmu.write(sym_file['AREANUMBER'], 0) #    1 execute_subroutine(sym_file['LOADAREAPOINTER']) execute_subroutine(sym_file['INITIALIZEAREA']) metatile_data = [] for column_pos in range(48): execute_subroutine(sym_file['AREAPARSERCORE']) metatile_data.append([mmu.read_no_debug(sym_file['METATILEBUFFER'] + i) for i in range(13)]) execute_subroutine(sym_file['INCREMENTCOLUMNPOS']) 

يكتب الرمز الأعمدة الـ 48 الأولى من المستوى العالمي 1-1 إلى metatile_data ، باستخدام metatile_data IncrementColumnPos لزيادة المتغيرات الداخلية اللازمة لتتبع العمود الحالي.

وإليك محتويات metatile_data متراكبة على لقطات الشاشة من اللعبة (لا يتم عرض وحدات البايت بقيمة 0):


من الواضح أن البيانات metatile_data تتطابق بوضوح مع معلومات الخلفية.

ميتا جرافيكس


(لمشاهدة النتيجة النهائية ، يمكنك المتابعة فورًا إلى قسم "توصيل كل شيء معًا".)

الآن دعونا نكتشف كيفية تحويل عدد الملفات الفوقية المستلمة إلى صور حقيقية. تم اختراع الخطوات الموضحة أدناه عن طريق تحليل المصادر وقراءة الوثائق باستخدام Nesdev Wiki المذهل .

لفهم كيفية عرض كل ملف تعريف ، نحتاج أولاً إلى التحدث عن لوحات ألوان NES. يمكن لوحدة PPU لوحدة التحكم في NES بشكل عام تقديم 64 لونًا مختلفًا ، ولكن يتم تكرار الأسود عدة مرات (انظر Nesdev للحصول على التفاصيل ):


يمكن لكل مستوى ماريو استخدام 10 فقط من هذه الألوان الـ 64 للخلفية ، مقسمة إلى 4 لوحات بأربعة ألوان ؛ اللون الأول هو نفسه دائمًا. فيما يلي أربع لوحات للعالم 1-1:


دعنا الآن نلقي نظرة على مثال ثنائي لرقم تعريف. هنا هو الرقم المعدني لبلاط الحجر المتصدع ، وهو الأرض ذات المستوى 1-1:


يخبرنا فهرس اللوح عن اللوح الذي يجب استخدامه عند عرض اللمسات (في حالتنا ، اللوح 1). فهرس لوح الألوان هو أيضًا فهرس الصفيفين التاليين:

MetatileGraphics_Low:
.db <Palette0_MTiles, <Palette1_MTiles, <Palette2_MTiles, <Palette3_MTiles

MetatileGraphics_High:
.db >Palette0_MTiles, >Palette1_MTiles, >Palette2_MTiles, >Palette3_MTiles


يمنحنا الجمع بين هاتين المصفوفتين عنوان 16 بت ، والذي يشير في Palette1_Mtiles إلى Palette1_Mtiles :

Palette1_MTiles:
.db $a2, $a2, $a3, $a3 ;vertical rope
.db $99, $24, $99, $24 ;horizontal rope
.db $24, $a2, $3e, $3f ;left pulley
.db $5b, $5c, $24, $a3 ;right pulley
.db $24, $24, $24, $24 ;blank used for balance rope
.db $9d, $47, $9e, $47 ;castle top
.db $47, $47, $27, $27 ;castle window left
.db $47, $47, $47, $47 ;castle brick wall
.db $27, $27, $47, $47 ;castle window right
.db $a9, $47, $aa, $47 ;castle top w/ brick
.db $9b, $27, $9c, $27 ;entrance top
.db $27, $27, $27, $27 ;entrance bottom
.db $52, $52, $52, $52 ;green ledge stump
.db $80, $a0, $81, $a1 ;fence
.db $be, $be, $bf, $bf ;tree trunk
.db $75, $ba, $76, $bb ;mushroom stump top
.db $ba, $ba, $bb, $bb ;mushroom stump bottom
.db $45, $47, $45, $47 ;breakable brick w/ line
.db $47, $47, $47, $47 ;breakable brick
.db $45, $47, $45, $47 ;breakable brick (not used)
.db $b4, $b6, $b5, $b7 ;cracked rock terrain <--- This is the 20th line
.db $45, $47, $45, $47 ;brick with line (power-up)
.db $45, $47, $45, $47 ;brick with line (vine)
.db $45, $47, $45, $47 ;brick with line (star)
.db $45, $47, $45, $47 ;brick with line (coins)
...


عندما تضرب مؤشر metatile في 4 ، يصبح فهرس هذا الصفيف. يتم تنسيق البيانات في 4 سجلات لكل سطر ، لذلك يشير مثالنا المتغير إلى الخط العشرين ، والذي تم وضع علامة عليه بتعليق cracked rock terrain .

الإدخالات الأربعة لهذا الخط هي في الواقع معرفات البلاط: كل ملف تعريف يتكون من أربعة مربعات 8 × 8 بكسل مرتبة بالترتيب التالي - أعلى اليسار ، وأسفل اليسار ، وأعلى اليمين وأسفل اليمين. يتم تمرير هذه المعرفات مباشرة إلى وحدة تحكم NES PPU. يشير المعرف إلى 16 بايت من البيانات في وحدة تحكم CHR-ROM ، ويبدأ كل سجل بالعنوان 0x1000 + 16 * < > :

0x1000 + 16 * 0xb4: 0b01111111 0x1000 + 16 * 0xb5: 0b11011110
0x1001 + 16 * 0xb4: 0b10000000 0x1001 + 16 * 0xb5: 0b01100001
0x1002 + 16 * 0xb4: 0b10000000 0x1002 + 16 * 0xb5: 0b01100001
0x1003 + 16 * 0xb4: 0b10000000 0x1003 + 16 * 0xb5: 0b01100001
0x1004 + 16 * 0xb4: 0b10000000 0x1004 + 16 * 0xb5: 0b01110001
0x1005 + 16 * 0xb4: 0b10000000 0x1005 + 16 * 0xb5: 0b01011110
0x1006 + 16 * 0xb4: 0b10000000 0x1006 + 16 * 0xb5: 0b01111111
0x1007 + 16 * 0xb4: 0b10000000 0x1007 + 16 * 0xb5: 0b01100001
0x1008 + 16 * 0xb4: 0b10000000 0x1008 + 16 * 0xb5: 0b01100001
0x1009 + 16 * 0xb4: 0b01111111 0x1009 + 16 * 0xb5: 0b11011111
0x100a + 16 * 0xb4: 0b01111111 0x100a + 16 * 0xb5: 0b11011111
0x100b + 16 * 0xb4: 0b01111111 0x100b + 16 * 0xb5: 0b11011111
0x100c + 16 * 0xb4: 0b01111111 0x100c + 16 * 0xb5: 0b11011111
0x100d + 16 * 0xb4: 0b01111111 0x100d + 16 * 0xb5: 0b11111111
0x100e + 16 * 0xb4: 0b01111111 0x100e + 16 * 0xb5: 0b11000001
0x100f + 16 * 0xb4: 0b01111111 0x100f + 16 * 0xb5: 0b11011111

0x1000 + 16 * 0xb6: 0b10000000 0x1000 + 16 * 0xb7: 0b01100001
0x1001 + 16 * 0xb6: 0b10000000 0x1001 + 16 * 0xb7: 0b01100001
0x1002 + 16 * 0xb6: 0b11000000 0x1002 + 16 * 0xb7: 0b11000001
0x1003 + 16 * 0xb6: 0b11110000 0x1003 + 16 * 0xb7: 0b11000001
0x1004 + 16 * 0xb6: 0b10111111 0x1004 + 16 * 0xb7: 0b10000001
0x1005 + 16 * 0xb6: 0b10001111 0x1005 + 16 * 0xb7: 0b10000001
0x1006 + 16 * 0xb6: 0b10000001 0x1006 + 16 * 0xb7: 0b10000011
0x1007 + 16 * 0xb6: 0b01111110 0x1007 + 16 * 0xb7: 0b11111110
0x1008 + 16 * 0xb6: 0b01111111 0x1008 + 16 * 0xb7: 0b11011111
0x1009 + 16 * 0xb6: 0b01111111 0x1009 + 16 * 0xb7: 0b11011111
0x100a + 16 * 0xb6: 0b11111111 0x100a + 16 * 0xb7: 0b10111111
0x100b + 16 * 0xb6: 0b00111111 0x100b + 16 * 0xb7: 0b10111111
0x100c + 16 * 0xb6: 0b01001111 0x100c + 16 * 0xb7: 0b01111111
0x100d + 16 * 0xb6: 0b01110001 0x100d + 16 * 0xb7: 0b01111111
0x100e + 16 * 0xb6: 0b01111111 0x100e + 16 * 0xb7: 0b01111111
0x100f + 16 * 0xb6: 0b11111111 0x100f + 16 * 0xb7: 0b01111111


CHR-ROM هو جزء من الذاكرة للقراءة فقط يمكن فقط لـ PPU الوصول إليه. يتم فصله عن PRG-ROM ، الذي يخزن رمز البرنامج. لذلك ، البيانات المذكورة أعلاه غير متوفرة في شفرة المصدر ويجب الحصول عليها من تفريغ ROM من اللعبة.

تشكل 16 بايت لكل تجانب تجانب 8 × 8 بت: البت الأول هو 8 بايت الأول ، والثاني هو 8 بايت الثاني:

21111111 13211112
12222222 23122223
12222222 23122223
12222222 23122223
12222222 23132223
12222222 23233332
12222222 23111113
12222222 23122223

12222222 23122223
12222222 23122223
33222222 31222223
11332222 31222223
12113333 12222223
12221113 12222223
12222223 12222233
23333332 13333332


اربط هذه البيانات باللوحة 1:


... وجمع القطع:


أخيرا حصلنا على بلاطة مقدمة.

ضع كل ذلك معًا


بتكرار هذا الإجراء لكل ملف تعريف ، نحصل على مستوى معروض بالكامل.


وبفضل هذا ، تمكنا من استخراج رسومات مستوى SMB باستخدام Python!

Source: https://habr.com/ru/post/ar416241/


All Articles